loaders 0.0.0

A fully-featured, customisable progress bar and loading indicator library for Rust CLI and terminal applications
Documentation
//! Low-level terminal rendering for progress lines.

use crate::terminal::writer::DrawTarget;

/// Mutable renderer bookkeeping.
///
/// The renderer tracks how many terminal lines were written last time so it can
/// clear or redraw multi-line output predictably.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RenderState {
    /// Length of the final line from the previous draw.
    pub last_line_len: usize,
    /// Number of lines written by the previous draw.
    pub lines_above: usize,
}

/// Draws progress lines to a `DrawTarget`.
///
/// The renderer writes ANSI cursor controls only for terminal targets. Custom
/// writers and non-TTY streams receive newline-separated snapshots.
#[derive(Debug)]
pub struct Renderer {
    pub(crate) target: DrawTarget,
    pub(crate) render_state: RenderState,
}

impl Renderer {
    /// Creates a renderer for a draw target.
    ///
    /// # Examples
    ///
    /// ```rust
    /// let mut renderer = loaders::bar::render::Renderer::new(loaders::DrawTarget::hidden());
    /// renderer.draw("hidden");
    /// ```
    pub fn new(target: DrawTarget) -> Renderer {
        Renderer {
            target,
            render_state: RenderState::default(),
        }
    }

    /// Draws a line, replacing the previous progress block when possible.
    ///
    /// # Examples
    ///
    /// ```rust
    /// let mut renderer = loaders::bar::render::Renderer::new(loaders::DrawTarget::hidden());
    /// renderer.draw("work");
    /// ```
    pub fn draw(&mut self, line: &str) {
        if self.target.is_hidden() {
            return;
        }

        let line_count = line.lines().count().max(1);
        if self.target.is_tty() {
            let mut out = String::new();
            if self.render_state.lines_above > 1 {
                out.push_str(&format!("\x1b[{}A", self.render_state.lines_above - 1));
            }
            out.push('\r');
            for (idx, rendered_line) in line.lines().enumerate() {
                if idx > 0 {
                    out.push('\n');
                }
                out.push_str("\x1b[K");
                out.push_str(rendered_line);
            }
            if line.is_empty() {
                out.push_str("\x1b[K");
            }
            self.target.write_str(&out);
        } else {
            self.target.write_str(line);
            self.target.write_str("\n");
        }
        self.target.flush();
        self.render_state.last_line_len = line.lines().last().map(str::len).unwrap_or(0);
        self.render_state.lines_above = line_count;
    }

    /// Clears the current progress block.
    ///
    /// # Examples
    ///
    /// ```rust
    /// let mut renderer = loaders::bar::render::Renderer::new(loaders::DrawTarget::hidden());
    /// renderer.clear();
    /// ```
    pub fn clear(&mut self) {
        if self.target.is_hidden() {
            return;
        }

        if self.target.is_tty() {
            let mut out = String::new();
            if self.render_state.lines_above > 1 {
                out.push_str(&format!("\x1b[{}A", self.render_state.lines_above - 1));
            }
            for idx in 0..self.render_state.lines_above.max(1) {
                if idx > 0 {
                    out.push('\n');
                }
                out.push_str("\r\x1b[K");
            }
            self.target.write_str(&out);
        }
        self.target.flush();
        self.render_state = RenderState::default();
    }

    /// Prints a message above the progress line.
    ///
    /// # Examples
    ///
    /// ```rust
    /// let mut renderer = loaders::bar::render::Renderer::new(loaders::DrawTarget::hidden());
    /// renderer.println("hello");
    /// ```
    pub fn println(&mut self, msg: &str) {
        if self.target.is_hidden() {
            return;
        }
        if self.target.is_tty() {
            self.target.write_str(&format!("\r\x1b[K{msg}\n"));
        } else {
            self.target.write_str(msg);
            self.target.write_str("\n");
        }
        self.target.flush();
    }

    /// Draws a final line and moves to the next terminal line.
    ///
    /// # Examples
    ///
    /// ```rust
    /// let mut renderer = loaders::bar::render::Renderer::new(loaders::DrawTarget::hidden());
    /// renderer.finish("done");
    /// ```
    pub fn finish(&mut self, line: &str) {
        if self.target.is_hidden() {
            return;
        }
        if self.target.is_tty() {
            self.draw(line);
            self.target.write_str("\n");
        } else {
            self.target.write_str(line);
            self.target.write_str("\n");
        }
        self.target.flush();
        self.render_state = RenderState::default();
    }

    pub(crate) fn set_target(&mut self, target: DrawTarget) {
        self.target = target;
        self.render_state = RenderState::default();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{self, Write};
    use std::sync::{Arc, Mutex};

    #[derive(Clone, Default)]
    struct SharedBuffer(Arc<Mutex<Vec<u8>>>);

    impl SharedBuffer {
        fn contents(&self) -> String {
            let bytes = match self.0.lock() {
                Ok(inner) => inner.clone(),
                Err(poisoned) => poisoned.into_inner().clone(),
            };
            String::from_utf8_lossy(&bytes).into_owned()
        }
    }

    impl Write for SharedBuffer {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            match self.0.lock() {
                Ok(mut inner) => inner.write(buf),
                Err(poisoned) => poisoned.into_inner().write(buf),
            }
        }

        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    #[test]
    fn test_renderer_draw_hidden_target_no_output() {
        let mut renderer = Renderer::new(DrawTarget::hidden());
        renderer.draw("hello");
        assert_eq!(renderer.render_state.last_line_len, 0);
    }

    #[test]
    fn test_renderer_draw_produces_output() {
        let buffer = SharedBuffer::default();
        let mirror = buffer.clone();
        let mut renderer = Renderer::new(DrawTarget::to_writer(buffer));
        renderer.draw("hello");
        assert!(mirror.contents().contains("hello"));
    }

    #[test]
    fn test_renderer_clear() {
        let buffer = SharedBuffer::default();
        let mut renderer = Renderer::new(DrawTarget::to_writer(buffer));
        renderer.draw("hello");
        renderer.clear();
        assert_eq!(renderer.render_state.last_line_len, 0);
    }

    #[test]
    fn test_println_above_bar() {
        let buffer = SharedBuffer::default();
        let mirror = buffer.clone();
        let mut renderer = Renderer::new(DrawTarget::to_writer(buffer));
        renderer.println("message");
        assert!(mirror.contents().contains("message"));
    }
}