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        (cols.saturating_sub(w) / 2, vr.saturating_sub(h + 1) / 2)
389    } else {
390        let _ = execute!(out, Hide);
391        (0, 0)
392    };
393
394    let frame = Duration::from_millis(1000 / FPS);
395    let start = Instant::now();
396    let mut displayed = 0.0f32;
397    let mut first = true;
398
399    loop {
400        let finishing = shared.state.load(Acquire) != RUNNING;
401        let total = shared.total.load(Relaxed);
402        let pos = shared.pos.load(Relaxed);
403        let t = start.elapsed().as_secs_f32();
404        let target = if total == 0 {
405            0.1 + 0.9 * (0.5 - 0.5 * (t * 1.5).cos()) // spinner: a breathing reveal
406        } else {
407            (pos as f32 / total as f32).clamp(0.0, 1.0)
408        };
409        displayed += (target - displayed) * 0.3; // glide toward the true value
410        let progress = if finishing { 1.0 } else { displayed };
411
412        let _ = if fullscreen {
413            draw_frame(&mut out, &shared, ox, oy, progress, t)
414        } else {
415            draw_inline(&mut out, &shared, progress, t, first)
416        };
417        first = false;
418
419        if finishing {
420            let cleared = shared.state.load(Relaxed) == FINISH_CLEAR;
421            if fullscreen {
422                let _ = execute!(out, ResetColor, Show, LeaveAlternateScreen);
423                if !cleared {
424                    let _ = persist_final(&mut out, &shared);
425                }
426            } else if cleared {
427                let _ = clear_inline(&mut out, h + 1);
428                let _ = execute!(out, Show);
429            } else {
430                // Leave the finished art in place and park the cursor below it.
431                let _ = queue!(out, Print("\r\n"));
432                let _ = execute!(out, Show);
433            }
434            let _ = out.flush();
435            break;
436        }
437        thread::sleep(frame);
438    }
439}
440
441fn draw_frame(
442    out: &mut io::Stdout,
443    shared: &Shared,
444    ox: u16,
445    oy: u16,
446    progress: f32,
447    t: f32,
448) -> io::Result<()> {
449    let art = &shared.art;
450    let (w, h) = (art.width(), art.height());
451    let style = &shared.style;
452
453    for y in 0..h {
454        queue!(out, MoveTo(ox, oy + y))?;
455        let mut last: Option<(u8, u8, u8)> = None;
456        for x in 0..w {
457            match shared.ranks.rank_at(x, y) {
458                Some(r) if r <= progress => {
459                    if style.color {
460                        let c = crate::render::cell_rgb(style, progress, r, x, y, t);
461                        if last != Some(c) {
462                            queue!(
463                                out,
464                                SetForegroundColor(Color::Rgb {
465                                    r: c.0,
466                                    g: c.1,
467                                    b: c.2
468                                })
469                            )?;
470                            last = Some(c);
471                        }
472                    }
473                    queue!(out, Print(art.glyph(x, y)))?;
474                }
475                _ => {
476                    if last.take().is_some() {
477                        queue!(out, ResetColor)?;
478                    }
479                    queue!(out, Print(' '))?;
480                }
481            }
482        }
483        if last.is_some() {
484            queue!(out, ResetColor)?;
485        }
486    }
487
488    // Caption row beneath the art.
489    queue!(out, MoveTo(ox, oy + h), Clear(ClearType::CurrentLine))?;
490    let msg = shared
491        .message
492        .lock()
493        .ok()
494        .map(|m| m.clone())
495        .unwrap_or_default();
496    if !msg.is_empty() {
497        let cols = terminal::size().map(|(c, _)| c).unwrap_or(80);
498        let shown: String = msg.chars().take(cols.saturating_sub(1) as usize).collect();
499        if style.color {
500            queue!(
501                out,
502                SetForegroundColor(Color::Rgb {
503                    r: 120,
504                    g: 134,
505                    b: 168
506                })
507            )?;
508        }
509        queue!(out, Print(shown), ResetColor)?;
510    }
511    out.flush()
512}
513
514// Inline reveal: draw the block in place and keep the cursor on its last line so the
515// next frame can step back up to it. The next program output then flows in below.
516fn draw_inline(
517    out: &mut io::Stdout,
518    shared: &Shared,
519    progress: f32,
520    t: f32,
521    first: bool,
522) -> io::Result<()> {
523    let art = &shared.art;
524    let (w, h) = (art.width(), art.height());
525    let style = &shared.style;
526
527    if !first {
528        queue!(out, MoveToPreviousLine(h))?;
529    }
530    for y in 0..h {
531        queue!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
532        let mut last: Option<(u8, u8, u8)> = None;
533        for x in 0..w {
534            match shared.ranks.rank_at(x, y) {
535                Some(r) if r <= progress => {
536                    if style.color {
537                        let c = crate::render::cell_rgb(style, progress, r, x, y, t);
538                        if last != Some(c) {
539                            queue!(
540                                out,
541                                SetForegroundColor(Color::Rgb {
542                                    r: c.0,
543                                    g: c.1,
544                                    b: c.2
545                                })
546                            )?;
547                            last = Some(c);
548                        }
549                    }
550                    queue!(out, Print(art.glyph(x, y)))?;
551                }
552                _ => {
553                    if last.take().is_some() {
554                        queue!(out, ResetColor)?;
555                    }
556                    queue!(out, Print(' '))?;
557                }
558            }
559        }
560        if last.is_some() {
561            queue!(out, ResetColor)?;
562        }
563        queue!(out, MoveToNextLine(1))?;
564    }
565
566    // Caption line; leave the cursor here for the next frame to step back up to.
567    queue!(out, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
568    let msg = shared
569        .message
570        .lock()
571        .ok()
572        .map(|m| m.clone())
573        .unwrap_or_default();
574    if !msg.is_empty() {
575        let cols = terminal::size().map(|(c, _)| c).unwrap_or(80);
576        let shown: String = msg.chars().take(cols.saturating_sub(1) as usize).collect();
577        if style.color {
578            queue!(
579                out,
580                SetForegroundColor(Color::Rgb {
581                    r: 120,
582                    g: 134,
583                    b: 168
584                })
585            )?;
586        }
587        queue!(out, Print(shown), ResetColor)?;
588    }
589    out.flush()
590}
591
592// Erase an inline block (the cursor is on its last line) and park it at the top.
593fn clear_inline(out: &mut io::Stdout, lines: u16) -> io::Result<()> {
594    queue!(out, MoveToPreviousLine(lines - 1))?;
595    for _ in 0..lines {
596        queue!(
597            out,
598            MoveToColumn(0),
599            Clear(ClearType::CurrentLine),
600            MoveToNextLine(1)
601        )?;
602    }
603    queue!(out, MoveToPreviousLine(lines))?;
604    out.flush()
605}
606
607// Print the finished art, coloured and trimmed, into the normal buffer so it stays.
608fn persist_final(out: &mut io::Stdout, shared: &Shared) -> io::Result<()> {
609    let art = &shared.art;
610    let (w, h) = (art.width(), art.height());
611    let style = &shared.style;
612    for y in 0..h {
613        let mut last_ink = 0u16;
614        let mut any = false;
615        for x in 0..w {
616            if art.is_ink(x, y) {
617                last_ink = x;
618                any = true;
619            }
620        }
621        if any {
622            let mut last: Option<(u8, u8, u8)> = None;
623            for x in 0..=last_ink {
624                if art.is_ink(x, y) {
625                    if style.color {
626                        let c = crate::render::cell_rgb(style, 1.0, 0.0, x, y, 0.0);
627                        if last != Some(c) {
628                            queue!(
629                                out,
630                                SetForegroundColor(Color::Rgb {
631                                    r: c.0,
632                                    g: c.1,
633                                    b: c.2
634                                })
635                            )?;
636                            last = Some(c);
637                        }
638                    }
639                    queue!(out, Print(art.glyph(x, y)))?;
640                } else {
641                    if last.take().is_some() {
642                        queue!(out, ResetColor)?;
643                    }
644                    queue!(out, Print(' '))?;
645                }
646            }
647            if last.is_some() {
648                queue!(out, ResetColor)?;
649            }
650        }
651        queue!(out, Print("\r\n"))?;
652    }
653    out.flush()
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::art::Art;
660
661    #[test]
662    fn loader_and_handle_are_send_sync() {
663        fn assert_send_sync<T: Send + Sync>() {}
664        assert_send_sync::<Loader>();
665        assert_send_sync::<Handle>();
666    }
667
668    #[test]
669    fn position_tracks_updates() {
670        let loader = Loader::builder().total(10).message("x").start();
671        loader.inc(3);
672        loader.set(7);
673        assert_eq!(loader.position(), 7);
674        loader.finish_and_clear();
675    }
676
677    #[test]
678    fn iterator_yields_every_item() {
679        let loader = Loader::builder().total(5).art(Art::parse("##")).start();
680        let collected: Vec<i32> = (0..5).inkling_with(loader).collect();
681        assert_eq!(collected, vec![0, 1, 2, 3, 4]);
682    }
683}