Skip to main content

inkling/
loader.rs

1//! `Loader`: the ergonomic, thread-safe front door to Inkling.
2//!
3//! This is how most programs should use Inkling. Create a [`Loader`] with a total,
4//! advance it from anywhere with [`inc`](Loader::inc) or [`set`](Loader::set), and
5//! a background thread keeps a living reveal painted at ~30 fps until you
6//! [`finish`](Loader::finish). It mirrors the idioms people already expect from a
7//! progress bar:
8//!
9//! * **Drive it by hand** with `inc`/`set`, determinate or [`spinner`](Loader::spinner).
10//! * **Wrap an iterator**: `for x in items.inkling() { .. }`.
11//! * **Wrap a reader**: `loader.wrap_read(file)` advances by bytes read.
12//! * **Log around it** with [`println`](Loader::println) or
13//!   [`suspend`](Loader::suspend), which lift the art out of the way first.
14//!
15//! The handle is cheap to clone (via [`handle`](Loader::handle)) and `Send + Sync`,
16//! so worker threads can report progress while the render thread owns the terminal,
17//! which keeps all drawing on one thread and free of races. When stdout is not a
18//! TTY the loader does not animate; it prints the finished art once on `finish`, so
19//! logs and CI still show the result.
20
21use 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
41/// The built-in art used when you do not supply your own.
42const DEFAULT_ART: &str = include_str!("../assets/dragon.txt");
43const FPS: u64 = 30;
44
45/// Time constant of the glide toward the true progress value, in seconds. Applied
46/// as `1 - exp(-dt / TAU)` so the smoothing is the same however fast we redraw.
47const GLIDE_TAU: f32 = 0.12;
48
49// Loader lifecycle, stored in `Shared::state`.
50const RUNNING: u8 = 0;
51const FINISH_KEEP: u8 = 1; // complete the art and leave it on screen
52const FINISH_CLEAR: u8 = 2; // complete and erase the art
53
54/// State shared between the public handles and the render thread.
55struct Shared {
56    pos: AtomicU64,
57    total: AtomicU64, // 0 means indeterminate (spinner)
58    state: AtomicU8,
59    /// Lines the inline block currently occupies on screen. `0` means nothing is
60    /// drawn, so the next frame starts fresh instead of stepping back over stale
61    /// output. Suspending resets it, which is what lets a log line land cleanly.
62    drawn_lines: AtomicU16,
63    message: Mutex<String>,
64    /// Held for the duration of a frame. [`Loader::suspend`] takes it so it can
65    /// never interleave with a paint.
66    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        // One choke point for every caption, whichever handle set it, so nothing
83        // that reaches the terminal carries control characters. See
84        // [`width::sanitize`].
85        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    /// Lock the paint mutex, tolerating a poisoned lock: a panicking painter must
97    /// not wedge every later frame.
98    fn lock_paint(&self) -> MutexGuard<'_, ()> {
99        self.painting.lock().unwrap_or_else(|e| e.into_inner())
100    }
101    /// Progress in `0..=1`, already eased. Indeterminate loaders breathe instead.
102    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()) // spinner
106        } 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
113/// A live progress reveal.
114///
115/// Create one with [`Loader::new`], advance it, and [`finish`](Loader::finish).
116/// Dropping the last handle finishes it for you, and the terminal is restored on
117/// panic and on Ctrl+C too. Not `Clone`; for cross-thread updates take a
118/// [`Handle`].
119pub struct Loader {
120    shared: Arc<Shared>,
121    joiner: Mutex<Option<JoinHandle<()>>>,
122    tty: bool,
123}
124
125impl Loader {
126    /// A determinate loader for `total` units of work, using the built-in dragon.
127    pub fn new(total: u64) -> Self {
128        Builder::new().total(total).start()
129    }
130
131    /// An indeterminate loader (a spinner) for work whose length you do not know.
132    pub fn spinner() -> Self {
133        Builder::new().start()
134    }
135
136    /// Configure a loader with custom art, ordering, style, easing, or message.
137    pub fn builder() -> Builder {
138        Builder::new()
139    }
140
141    /// Advance the position by `delta`.
142    pub fn inc(&self, delta: u64) {
143        self.shared.inc(delta);
144    }
145
146    /// Set the absolute position.
147    pub fn set(&self, pos: u64) {
148        self.shared.set(pos);
149    }
150
151    /// Change the total amount of work.
152    pub fn set_length(&self, total: u64) {
153        self.shared.total.store(total, Relaxed);
154    }
155
156    /// Set a short caption shown beneath the art.
157    pub fn set_message<S: Into<String>>(&self, msg: S) {
158        self.shared.set_message(msg.into());
159    }
160
161    /// The current position.
162    pub fn position(&self) -> u64 {
163        self.shared.pos.load(Relaxed)
164    }
165
166    /// The total amount of work, or `0` when indeterminate.
167    pub fn length(&self) -> u64 {
168        self.shared.total.load(Relaxed)
169    }
170
171    /// Time since the loader started.
172    pub fn elapsed(&self) -> Duration {
173        self.shared.started.elapsed()
174    }
175
176    /// Average units of work per second so far, or `0.0` before any time passes.
177    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    /// Estimated time remaining, from the average rate so far.
187    ///
188    /// `None` for an indeterminate loader, before enough has happened to
189    /// extrapolate from, or once the work is done.
190    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    /// A cheap, clonable, `Send + Sync` handle for reporting progress from other
200    /// threads. Handles can update but not finish the loader.
201    pub fn handle(&self) -> Handle {
202        Handle {
203            shared: Arc::clone(&self.shared),
204        }
205    }
206
207    /// Wrap a reader so every byte read advances the loader. Ideal for downloads:
208    /// set the length to the content length, then read through the wrapper.
209    pub fn wrap_read<R: Read>(&self, reader: R) -> ProgressReader<R> {
210        self.handle().wrap_read(reader)
211    }
212
213    /// Lift the art out of the way, run `f`, and let the reveal redraw beneath
214    /// whatever it printed.
215    ///
216    /// Without this, anything your program writes to the terminal lands in the
217    /// middle of the block and desynchronises the renderer, which is tracking
218    /// where its own output sits. Use it for any logging you want interleaved with
219    /// a live reveal.
220    ///
221    /// ```no_run
222    /// # use inkling::Loader;
223    /// let loader = Loader::new(3);
224    /// loader.suspend(|| eprintln!("something worth saying"));
225    /// ```
226    pub fn suspend<T>(&self, f: impl FnOnce() -> T) -> T {
227        if !self.tty {
228            return f();
229        }
230        // Wait for any frame in flight, then take the block off the screen so the
231        // caller's output starts on a clean line.
232        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    /// Print a line above the reveal, which then redraws beneath it.
243    ///
244    /// Shorthand for [`suspend`](Self::suspend) around a `println!`.
245    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    /// Fill the art, leave it on screen, and restore the terminal.
254    pub fn finish(&self) {
255        self.finalize(FINISH_KEEP);
256    }
257
258    /// Finish and erase the art from the screen.
259    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            // No animation off a TTY; leave the finished art for logs and CI.
277            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/// A cheap, clonable updater obtained from [`Loader::handle`]. Safe to send to and
293/// share across threads.
294#[derive(Clone)]
295pub struct Handle {
296    shared: Arc<Shared>,
297}
298
299impl Handle {
300    /// Advance the position by `delta`.
301    pub fn inc(&self, delta: u64) {
302        self.shared.inc(delta);
303    }
304    /// Set the absolute position.
305    pub fn set(&self, pos: u64) {
306        self.shared.set(pos);
307    }
308    /// Set the caption.
309    pub fn set_message<S: Into<String>>(&self, msg: S) {
310        self.shared.set_message(msg.into());
311    }
312    /// The current position.
313    pub fn position(&self) -> u64 {
314        self.shared.pos.load(Relaxed)
315    }
316    /// Wrap a reader so every byte read advances the loader, from any thread.
317    pub fn wrap_read<R: Read>(&self, reader: R) -> ProgressReader<R> {
318        ProgressReader {
319            inner: reader,
320            handle: self.clone(),
321        }
322    }
323}
324
325/// Builder for a customised [`Loader`].
326pub 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    /// Units of work. Leave it `0` (the default) for an indeterminate spinner.
348    pub fn total(mut self, total: u64) -> Self {
349        self.total = total;
350        self
351    }
352
353    /// The art to reveal. Defaults to the built-in dragon.
354    pub fn art(mut self, art: Art) -> Self {
355        self.art = Some(art);
356        self
357    }
358
359    /// The ordering that decides the reveal path. Defaults to [`Directional`].
360    pub fn ordering(mut self, ordering: impl Ordering + 'static) -> Self {
361        self.ordering = Box::new(ordering);
362        self
363    }
364
365    /// Colours, frontier glow, and colour depth.
366    pub fn style(mut self, style: Style) -> Self {
367        self.style = style;
368        self
369    }
370
371    /// The curve mapping raw completion onto revealed progress. Defaults to
372    /// [`Easing::Linear`]. Ignored by indeterminate spinners.
373    pub fn easing(mut self, easing: Easing) -> Self {
374        self.easing = easing;
375        self
376    }
377
378    /// A short caption shown beneath the art.
379    pub fn message<S: Into<String>>(mut self, message: S) -> Self {
380        self.message = width::sanitize(&message.into());
381        self
382    }
383
384    /// Build the loader and start animating (on a TTY).
385    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
416// ---------------------------------------------------------------------------
417// Iterator wrapping: `for x in items.inkling() { .. }`
418// ---------------------------------------------------------------------------
419
420/// Extension trait that wraps any iterator in a progress reveal.
421pub trait ProgressIteratorExt: Iterator + Sized {
422    /// Reveal a loader while iterating, inferring the total from `size_hint`.
423    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    /// Reveal a specific, pre-configured loader while iterating.
437    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
447/// Iterator adaptor returned by [`ProgressIteratorExt::inkling`].
448pub struct InklingIter<I> {
449    inner: I,
450    loader: Option<Loader>,
451}
452
453impl<I> InklingIter<I> {
454    /// The loader driving this iteration, for captions and logging.
455    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
493// ---------------------------------------------------------------------------
494// Reader wrapping: bytes read advance the loader.
495// ---------------------------------------------------------------------------
496
497/// A `Read` wrapper that advances a loader by the number of bytes read.
498pub struct ProgressReader<R> {
499    inner: R,
500    handle: Handle,
501}
502
503impl<R> ProgressReader<R> {
504    /// Recover the wrapped reader.
505    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// ---------------------------------------------------------------------------
529// The render thread: a reveal at ~30 fps.
530// ---------------------------------------------------------------------------
531
532/// How the block is positioned on screen.
533#[derive(Clone, Copy, PartialEq, Eq)]
534enum Placement {
535    /// In the flow of the terminal, so the next program output follows below it.
536    Inline,
537    /// Absolutely positioned in the alternate screen, for art too tall to inline.
538    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        // Re-read the viewport every frame so a resize is handled rather than
559        // smeared across the scrollback.
560        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        // Glide toward the true value with a fixed time constant, so the easing
576        // looks the same however often we redraw.
577        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                    // Leave the finished art in place and park the cursor below it.
606                    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
618/// Animate inline while the picture and its caption fit the viewport, which keeps
619/// the reveal in the flow of the terminal and lets the next output follow below
620/// it. Only when the art will not fit do we fall back to the alternate screen,
621/// where it cannot scroll and duplicate itself.
622fn 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
654/// Draw one frame. Both placements share the same row writer; they differ only in
655/// how the cursor gets to the start of each line.
656fn 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    // One row is reserved for the caption in both placements.
671    let fit = viewport.fit(art, 1);
672
673    queue!(out, Print(crate::render::SYNC_BEGIN))?;
674
675    // Step back over the block we drew last time, if it is still on screen.
676    // `drawn_lines` is the block's *height*, and the cursor was left on its last
677    // line, so the step back is one less than that. Moving the full height would
678    // walk the block one row up the screen every frame, climbing over whatever
679    // was printed above it and leaving a trail of stale caption lines below.
680    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    // Caption row beneath the art.
698    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    // The cursor now sits on the caption line, which is the last line of the
713    // block: record its height so the next frame knows how far to step back.
714    shared.drawn_lines.store(fit.rows + 1, Relaxed);
715
716    queue!(out, Print(crate::render::SYNC_END))?;
717    out.flush()
718}
719
720/// Erase an inline block of `lines` lines whose last line the cursor is on, and
721/// park the cursor back at its top.
722fn 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
742/// Print the finished art, coloured and trimmed, into the normal buffer so it
743/// stays in scrollback after the alternate screen is gone.
744fn persist_final(out: &mut impl Write, shared: &Shared, viewport: Viewport) -> io::Result<()> {
745    let art = &shared.art;
746    // Feather 0 means every cell reads as settled body colour rather than as a
747    // frontier that happens to sit at the end of the bar.
748    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    /// The inline block has to be exactly cursor-neutral: whatever it steps down
845    /// while drawing, the next frame steps back up. One line out either way and
846    /// the block walks up or down the screen once per frame at 30 fps, smearing
847    /// the scrollback permanently. Rendering into a buffer is how this stays
848    /// tested without a TTY.
849    #[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        // Three art rows plus the caption: a four-line block whose last line the
871        // cursor is sitting on.
872        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}