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//!
13//! The handle is cheap to clone (via [`handle`](Loader::handle)) and `Send + Sync`,
14//! so worker threads can report progress while the render thread owns the terminal,
15//! which keeps all drawing on one thread and free of races. When stdout is not a
16//! TTY the loader does not animate; it prints the finished art once on `finish`, so
17//! logs and CI still show the result.
18
19use std::io::{self, IsTerminal, Read, Write};
20use std::sync::atomic::Ordering::{AcqRel, Acquire, Relaxed};
21use std::sync::atomic::{AtomicU64, AtomicU8};
22use std::sync::{Arc, Mutex};
23use std::thread::{self, JoinHandle};
24use std::time::{Duration, Instant};
25
26use crossterm::{
27    cursor::{Hide, MoveTo, MoveToColumn, MoveToNextLine, MoveToPreviousLine, Show},
28    execute, queue,
29    style::{Color, Print, ResetColor, SetForegroundColor},
30    terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
31};
32
33use crate::art::Art;
34use crate::ordering::{Directional, Ordering};
35use crate::render::Style;
36
37/// The built-in art used when you do not supply your own.
38const DEFAULT_ART: &str = include_str!("../assets/dragon.txt");
39const FPS: u64 = 30;
40
41// Loader lifecycle, stored in `Shared::state`.
42const RUNNING: u8 = 0;
43const FINISH_KEEP: u8 = 1; // complete the art and leave it on screen
44const FINISH_CLEAR: u8 = 2; // complete and erase the art
45
46/// State shared between the public handles and the render thread.
47struct Shared {
48    pos: AtomicU64,
49    total: AtomicU64, // 0 means indeterminate (spinner)
50    state: AtomicU8,
51    message: Mutex<String>,
52    art: Art,
53    ranks: crate::rank::RankMap,
54    style: Style,
55}
56
57impl Shared {
58    fn inc(&self, delta: u64) {
59        self.pos.fetch_add(delta, Relaxed);
60    }
61    fn set(&self, pos: u64) {
62        self.pos.store(pos, Relaxed);
63    }
64    fn set_message(&self, msg: String) {
65        if let Ok(mut guard) = self.message.lock() {
66            *guard = msg;
67        }
68    }
69}
70
71/// A living progress reveal.
72///
73/// Create one with [`Loader::new`], advance it, and [`finish`](Loader::finish).
74/// Dropping the last handle finishes it for you, so the terminal is always
75/// restored. Not `Clone`; for cross-thread updates take a [`Handle`].
76pub struct Loader {
77    shared: Arc<Shared>,
78    joiner: Mutex<Option<JoinHandle<()>>>,
79    tty: bool,
80}
81
82impl Loader {
83    /// A determinate loader for `total` units of work, using the built-in dragon.
84    pub fn new(total: u64) -> Self {
85        Builder::new().total(total).start()
86    }
87
88    /// An indeterminate loader (a spinner) for work whose length you do not know.
89    pub fn spinner() -> Self {
90        Builder::new().start()
91    }
92
93    /// Configure a loader with custom art, ordering, style, or message.
94    pub fn builder() -> Builder {
95        Builder::new()
96    }
97
98    /// Advance the position by `delta`.
99    pub fn inc(&self, delta: u64) {
100        self.shared.inc(delta);
101    }
102
103    /// Set the absolute position.
104    pub fn set(&self, pos: u64) {
105        self.shared.set(pos);
106    }
107
108    /// Change the total amount of work.
109    pub fn set_length(&self, total: u64) {
110        self.shared.total.store(total, Relaxed);
111    }
112
113    /// Set a short caption shown beneath the art.
114    pub fn set_message<S: Into<String>>(&self, msg: S) {
115        self.shared.set_message(msg.into());
116    }
117
118    /// The current position.
119    pub fn position(&self) -> u64 {
120        self.shared.pos.load(Relaxed)
121    }
122
123    /// A cheap, clonable, `Send + Sync` handle for reporting progress from other
124    /// threads. Handles can update but not finish the loader.
125    pub fn handle(&self) -> Handle {
126        Handle {
127            shared: Arc::clone(&self.shared),
128        }
129    }
130
131    /// Wrap a reader so every byte read advances the loader. Ideal for downloads:
132    /// set the length to the content length, then read through the wrapper.
133    pub fn wrap_read<R: Read>(&self, reader: R) -> ProgressReader<R> {
134        ProgressReader {
135            inner: reader,
136            handle: self.handle(),
137        }
138    }
139
140    /// Fill the art, leave it on screen, and restore the terminal.
141    pub fn finish(&self) {
142        self.finalize(FINISH_KEEP);
143    }
144
145    /// Finish and erase the art from the screen.
146    pub fn finish_and_clear(&self) {
147        self.finalize(FINISH_CLEAR);
148    }
149
150    fn finalize(&self, how: u8) {
151        let won = self
152            .shared
153            .state
154            .compare_exchange(RUNNING, how, AcqRel, Relaxed)
155            .is_ok();
156        if self.tty {
157            if let Ok(mut guard) = self.joiner.lock() {
158                if let Some(handle) = guard.take() {
159                    let _ = handle.join();
160                }
161            }
162        } else if won && how == FINISH_KEEP {
163            // No animation off a TTY; leave the finished art for logs and CI.
164            print!(
165                "{}",
166                crate::frame::to_string(&self.shared.art, &self.shared.ranks, 1.0)
167            );
168            let _ = io::stdout().flush();
169        }
170    }
171}
172
173impl Drop for Loader {
174    fn drop(&mut self) {
175        self.finalize(FINISH_KEEP);
176    }
177}
178
179/// A cheap, clonable updater obtained from [`Loader::handle`]. Safe to send to and
180/// share across threads.
181#[derive(Clone)]
182pub struct Handle {
183    shared: Arc<Shared>,
184}
185
186impl Handle {
187    /// Advance the position by `delta`.
188    pub fn inc(&self, delta: u64) {
189        self.shared.inc(delta);
190    }
191    /// Set the absolute position.
192    pub fn set(&self, pos: u64) {
193        self.shared.set(pos);
194    }
195    /// Set the caption.
196    pub fn set_message<S: Into<String>>(&self, msg: S) {
197        self.shared.set_message(msg.into());
198    }
199    /// The current position.
200    pub fn position(&self) -> u64 {
201        self.shared.pos.load(Relaxed)
202    }
203}
204
205/// Builder for a customised [`Loader`].
206pub struct Builder {
207    total: u64,
208    art: Option<Art>,
209    ordering: Box<dyn Ordering>,
210    style: Style,
211    message: String,
212}
213
214impl Builder {
215    fn new() -> Self {
216        Builder {
217            total: 0,
218            art: None,
219            ordering: Box::new(Directional::default()),
220            style: Style::default(),
221            message: String::new(),
222        }
223    }
224
225    /// Units of work. Leave it `0` (the default) for an indeterminate spinner.
226    pub fn total(mut self, total: u64) -> Self {
227        self.total = total;
228        self
229    }
230
231    /// The art to reveal. Defaults to the built-in dragon.
232    pub fn art(mut self, art: Art) -> Self {
233        self.art = Some(art);
234        self
235    }
236
237    /// The ordering that decides the reveal path. Defaults to [`Directional`].
238    pub fn ordering(mut self, ordering: impl Ordering + 'static) -> Self {
239        self.ordering = Box::new(ordering);
240        self
241    }
242
243    /// Colours and frontier glow.
244    pub fn style(mut self, style: Style) -> Self {
245        self.style = style;
246        self
247    }
248
249    /// A short caption shown beneath the art.
250    pub fn message<S: Into<String>>(mut self, message: S) -> Self {
251        self.message = message.into();
252        self
253    }
254
255    /// Build the loader and start animating (on a TTY).
256    pub fn start(self) -> Loader {
257        let art = self.art.unwrap_or_else(|| Art::parse(DEFAULT_ART));
258        let ranks = self.ordering.rank(&art);
259        let shared = Arc::new(Shared {
260            pos: AtomicU64::new(0),
261            total: AtomicU64::new(self.total),
262            state: AtomicU8::new(RUNNING),
263            message: Mutex::new(self.message),
264            art,
265            ranks,
266            style: self.style,
267        });
268        let tty = io::stdout().is_terminal();
269        let joiner = if tty {
270            let shared = Arc::clone(&shared);
271            Mutex::new(Some(thread::spawn(move || run(shared))))
272        } else {
273            Mutex::new(None)
274        };
275        Loader {
276            shared,
277            joiner,
278            tty,
279        }
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Iterator wrapping: `for x in items.inkling() { .. }`
285// ---------------------------------------------------------------------------
286
287/// Extension trait that wraps any iterator in a progress reveal.
288pub trait ProgressIteratorExt: Iterator + Sized {
289    /// Reveal a loader while iterating, inferring the total from `size_hint`.
290    fn inkling(self) -> InklingIter<Self> {
291        let total = self.size_hint().1.unwrap_or(0) as u64;
292        let loader = if total > 0 {
293            Loader::new(total)
294        } else {
295            Loader::spinner()
296        };
297        InklingIter {
298            inner: self,
299            loader: Some(loader),
300        }
301    }
302
303    /// Reveal a specific, pre-configured loader while iterating.
304    fn inkling_with(self, loader: Loader) -> InklingIter<Self> {
305        InklingIter {
306            inner: self,
307            loader: Some(loader),
308        }
309    }
310}
311
312impl<I: Iterator> ProgressIteratorExt for I {}
313
314/// Iterator adaptor returned by [`ProgressIteratorExt::inkling`].
315pub struct InklingIter<I> {
316    inner: I,
317    loader: Option<Loader>,
318}
319
320impl<I: Iterator> Iterator for InklingIter<I> {
321    type Item = I::Item;
322
323    fn next(&mut self) -> Option<Self::Item> {
324        let next = self.inner.next();
325        match next {
326            Some(_) => {
327                if let Some(loader) = &self.loader {
328                    loader.inc(1);
329                }
330            }
331            None => {
332                if let Some(loader) = self.loader.take() {
333                    loader.finish();
334                }
335            }
336        }
337        next
338    }
339
340    fn size_hint(&self) -> (usize, Option<usize>) {
341        self.inner.size_hint()
342    }
343}
344
345impl<I> Drop for InklingIter<I> {
346    fn drop(&mut self) {
347        if let Some(loader) = self.loader.take() {
348            loader.finish();
349        }
350    }
351}
352
353// ---------------------------------------------------------------------------
354// Reader wrapping: bytes read advance the loader.
355// ---------------------------------------------------------------------------
356
357/// A `Read` wrapper that advances a loader by the number of bytes read.
358pub struct ProgressReader<R> {
359    inner: R,
360    handle: Handle,
361}
362
363impl<R: Read> Read for ProgressReader<R> {
364    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
365        let n = self.inner.read(buf)?;
366        self.handle.inc(n as u64);
367        Ok(n)
368    }
369}
370
371// ---------------------------------------------------------------------------
372// The render thread: a reveal at ~30 fps in the alternate screen.
373// ---------------------------------------------------------------------------
374
375fn run(shared: Arc<Shared>) {
376    let mut out = io::stdout();
377    let (w, h) = (shared.art.width(), shared.art.height());
378    let rows = terminal::size().map(|(_, r)| r).unwrap_or(0);
379    // Animate inline while the picture and its caption fit the viewport, which keeps
380    // the reveal in the flow of the terminal and lets the next output follow below it.
381    // Only when the art is taller than the screen do we fall back to the alternate
382    // screen, where it cannot scroll and duplicate itself.
383    let fullscreen = rows < h + 2;
384
385    let (ox, oy) = if fullscreen {
386        let (cols, vr) = terminal::size().unwrap_or((w, h + 2));
387        let _ = execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All));
388        (
389            cols.saturating_sub(crate::render::art_cols(&shared.art)) / 2,
390            vr.saturating_sub(h + 1) / 2,
391        )
392    } else {
393        let _ = execute!(out, Hide);
394        (0, 0)
395    };
396
397    let frame = Duration::from_millis(1000 / FPS);
398    let start = Instant::now();
399    let mut displayed = 0.0f32;
400    let mut first = true;
401
402    loop {
403        let finishing = shared.state.load(Acquire) != RUNNING;
404        let total = shared.total.load(Relaxed);
405        let pos = shared.pos.load(Relaxed);
406        let t = start.elapsed().as_secs_f32();
407        let target = if total == 0 {
408            0.1 + 0.9 * (0.5 - 0.5 * (t * 1.5).cos()) // spinner: a breathing reveal
409        } else {
410            (pos as f32 / total as f32).clamp(0.0, 1.0)
411        };
412        displayed += (target - displayed) * 0.3; // glide toward the true value
413        let progress = if finishing { 1.0 } else { displayed };
414
415        let _ = if fullscreen {
416            draw_frame(&mut out, &shared, ox, oy, progress, t)
417        } else {
418            draw_inline(&mut out, &shared, progress, t, first)
419        };
420        first = false;
421
422        if finishing {
423            let cleared = shared.state.load(Relaxed) == FINISH_CLEAR;
424            if fullscreen {
425                let _ = execute!(out, ResetColor, Show, LeaveAlternateScreen);
426                if !cleared {
427                    let _ = persist_final(&mut out, &shared);
428                }
429            } else if cleared {
430                let _ = clear_inline(&mut out, h + 1);
431                let _ = execute!(out, Show);
432            } else {
433                // Leave the finished art in place and park the cursor below it.
434                let _ = queue!(out, Print("\r\n"));
435                let _ = execute!(out, Show);
436            }
437            let _ = out.flush();
438            break;
439        }
440        thread::sleep(frame);
441    }
442}
443
444fn draw_frame(
445    out: &mut io::Stdout,
446    shared: &Shared,
447    ox: u16,
448    oy: u16,
449    progress: f32,
450    t: f32,
451) -> io::Result<()> {
452    let art = &shared.art;
453    let (w, h) = (art.width(), art.height());
454    let style = &shared.style;
455
456    queue!(out, Print(crate::render::SYNC_BEGIN))?;
457    for y in 0..h {
458        queue!(out, MoveTo(ox, oy + y))?;
459        let mut last: Option<(u8, u8, u8)> = None;
460        for x in 0..w {
461            match shared.ranks.rank_at(x, y) {
462                Some(r) if r <= progress => {
463                    if style.color {
464                        let c = crate::render::cell_rgb(style, progress, r, x, y, t);
465                        if last != Some(c) {
466                            queue!(
467                                out,
468                                SetForegroundColor(Color::Rgb {
469                                    r: c.0,
470                                    g: c.1,
471                                    b: c.2
472                                })
473                            )?;
474                            last = Some(c);
475                        }
476                    }
477                    queue!(out, Print(art.glyph(x, y)))?;
478                }
479                _ => {
480                    if last.take().is_some() {
481                        queue!(out, ResetColor)?;
482                    }
483                    queue!(out, Print(' '))?;
484                }
485            }
486        }
487        if last.is_some() {
488            queue!(out, ResetColor)?;
489        }
490    }
491
492    // Caption row beneath the art.
493    queue!(out, MoveTo(ox, oy + h), Clear(ClearType::CurrentLine))?;
494    let msg = shared
495        .message
496        .lock()
497        .ok()
498        .map(|m| m.clone())
499        .unwrap_or_default();
500    if !msg.is_empty() {
501        let cols = terminal::size().map(|(c, _)| c).unwrap_or(80);
502        let shown = crate::render::truncate_to_cols(&msg, cols.saturating_sub(1));
503        if style.color {
504            queue!(
505                out,
506                SetForegroundColor(Color::Rgb {
507                    r: 120,
508                    g: 134,
509                    b: 168
510                })
511            )?;
512        }
513        queue!(out, Print(shown), ResetColor)?;
514    }
515    queue!(out, Print(crate::render::SYNC_END))?;
516    out.flush()
517}
518
519// Inline reveal: draw the block in place and keep the cursor on its last line so the
520// next frame can step back up to it. The next program output then flows in below.
521fn draw_inline(
522    out: &mut io::Stdout,
523    shared: &Shared,
524    progress: f32,
525    t: f32,
526    first: bool,
527) -> io::Result<()> {
528    let art = &shared.art;
529    let (w, h) = (art.width(), art.height());
530    let style = &shared.style;
531
532    queue!(out, Print(crate::render::SYNC_BEGIN))?;
533    if !first {
534        queue!(out, MoveToPreviousLine(h))?;
535    }
536    for y in 0..h {
537        queue!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
538        let mut last: Option<(u8, u8, u8)> = None;
539        for x in 0..w {
540            match shared.ranks.rank_at(x, y) {
541                Some(r) if r <= progress => {
542                    if style.color {
543                        let c = crate::render::cell_rgb(style, progress, r, x, y, t);
544                        if last != Some(c) {
545                            queue!(
546                                out,
547                                SetForegroundColor(Color::Rgb {
548                                    r: c.0,
549                                    g: c.1,
550                                    b: c.2
551                                })
552                            )?;
553                            last = Some(c);
554                        }
555                    }
556                    queue!(out, Print(art.glyph(x, y)))?;
557                }
558                _ => {
559                    if last.take().is_some() {
560                        queue!(out, ResetColor)?;
561                    }
562                    queue!(out, Print(' '))?;
563                }
564            }
565        }
566        if last.is_some() {
567            queue!(out, ResetColor)?;
568        }
569        queue!(out, MoveToNextLine(1))?;
570    }
571
572    // Caption line; leave the cursor here for the next frame to step back up to.
573    queue!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
574    let msg = shared
575        .message
576        .lock()
577        .ok()
578        .map(|m| m.clone())
579        .unwrap_or_default();
580    if !msg.is_empty() {
581        let cols = terminal::size().map(|(c, _)| c).unwrap_or(80);
582        let shown = crate::render::truncate_to_cols(&msg, cols.saturating_sub(1));
583        if style.color {
584            queue!(
585                out,
586                SetForegroundColor(Color::Rgb {
587                    r: 120,
588                    g: 134,
589                    b: 168
590                })
591            )?;
592        }
593        queue!(out, Print(shown), ResetColor)?;
594    }
595    queue!(out, Print(crate::render::SYNC_END))?;
596    out.flush()
597}
598
599// Erase an inline block (the cursor is on its last line) and park it at the top.
600fn clear_inline(out: &mut io::Stdout, lines: u16) -> io::Result<()> {
601    queue!(out, MoveToPreviousLine(lines - 1))?;
602    for _ in 0..lines {
603        queue!(
604            out,
605            MoveToColumn(0),
606            Clear(ClearType::CurrentLine),
607            MoveToNextLine(1)
608        )?;
609    }
610    queue!(out, MoveToPreviousLine(lines))?;
611    out.flush()
612}
613
614// Print the finished art, coloured and trimmed, into the normal buffer so it stays.
615fn persist_final(out: &mut io::Stdout, shared: &Shared) -> io::Result<()> {
616    let art = &shared.art;
617    let (w, h) = (art.width(), art.height());
618    let style = &shared.style;
619    for y in 0..h {
620        let mut last_ink = 0u16;
621        let mut any = false;
622        for x in 0..w {
623            if art.is_ink(x, y) {
624                last_ink = x;
625                any = true;
626            }
627        }
628        if any {
629            let mut last: Option<(u8, u8, u8)> = None;
630            for x in 0..=last_ink {
631                if art.is_ink(x, y) {
632                    if style.color {
633                        let c = crate::render::cell_rgb(style, 1.0, 0.0, x, y, 0.0);
634                        if last != Some(c) {
635                            queue!(
636                                out,
637                                SetForegroundColor(Color::Rgb {
638                                    r: c.0,
639                                    g: c.1,
640                                    b: c.2
641                                })
642                            )?;
643                            last = Some(c);
644                        }
645                    }
646                    queue!(out, Print(art.glyph(x, y)))?;
647                } else {
648                    if last.take().is_some() {
649                        queue!(out, ResetColor)?;
650                    }
651                    queue!(out, Print(' '))?;
652                }
653            }
654            if last.is_some() {
655                queue!(out, ResetColor)?;
656            }
657        }
658        queue!(out, Print("\r\n"))?;
659    }
660    out.flush()
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use crate::art::Art;
667
668    #[test]
669    fn loader_and_handle_are_send_sync() {
670        fn assert_send_sync<T: Send + Sync>() {}
671        assert_send_sync::<Loader>();
672        assert_send_sync::<Handle>();
673    }
674
675    #[test]
676    fn position_tracks_updates() {
677        let loader = Loader::builder().total(10).message("x").start();
678        loader.inc(3);
679        loader.set(7);
680        assert_eq!(loader.position(), 7);
681        loader.finish_and_clear();
682    }
683
684    #[test]
685    fn iterator_yields_every_item() {
686        let loader = Loader::builder().total(5).art(Art::parse("##")).start();
687        let collected: Vec<i32> = (0..5).inkling_with(loader).collect();
688        assert_eq!(collected, vec![0, 1, 2, 3, 4]);
689    }
690}