a3s-tui 0.1.4

TEA (The Elm Architecture) framework for terminal user interfaces
Documentation
use crate::style::{visible_len, Color, Style};

const BARS: [char; 8] = ['', '', '', '', '', '', '', ''];

#[derive(Debug, Clone)]
pub struct Sparkline {
    values: Vec<f64>,
    width: usize,
    min: Option<f64>,
    max: Option<f64>,
    fg: Color,
    empty: char,
}

impl Sparkline {
    pub fn new(values: impl IntoIterator<Item = f64>) -> Self {
        Self {
            values: values.into_iter().filter(|v| v.is_finite()).collect(),
            width: 10,
            min: Some(0.0),
            max: None,
            fg: Color::Cyan,
            empty: '·',
        }
    }

    pub fn width(mut self, width: usize) -> Self {
        self.width = width.max(1);
        self
    }

    pub fn range(mut self, min: f64, max: f64) -> Self {
        self.min = Some(min);
        self.max = Some(max);
        self
    }

    pub fn auto_range(mut self) -> Self {
        self.min = None;
        self.max = None;
        self
    }

    pub fn fg(mut self, color: Color) -> Self {
        self.fg = color;
        self
    }

    pub fn empty(mut self, ch: char) -> Self {
        self.empty = ch;
        self
    }

    pub fn view(&self) -> String {
        Style::new().fg(self.fg).render(&self.plain())
    }

    pub fn plain(&self) -> String {
        if self.width == 0 {
            return String::new();
        }
        if self.values.is_empty() {
            return self.empty.to_string().repeat(self.width);
        }

        let values = self.window_values();
        let observed_min = values.iter().copied().fold(f64::INFINITY, f64::min);
        let observed_max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
        let min = self.min.unwrap_or(observed_min);
        let max = self.max.unwrap_or(observed_max).max(min);
        let span = (max - min).max(f64::EPSILON);

        let mut out = values
            .iter()
            .map(|value| {
                let normalized = ((*value - min) / span).clamp(0.0, 1.0);
                let idx = (normalized * (BARS.len() - 1) as f64).round() as usize;
                BARS[idx]
            })
            .collect::<String>();

        let len = visible_len(&out);
        if len < self.width {
            out = format!("{}{}", self.empty.to_string().repeat(self.width - len), out);
        }
        out
    }

    fn window_values(&self) -> Vec<f64> {
        let len = self.values.len();
        if len <= self.width {
            return self.values.clone();
        }
        self.values[len - self.width..].to_vec()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::style::strip_ansi;

    #[test]
    fn renders_empty_placeholder() {
        let line = Sparkline::new(Vec::<f64>::new()).width(4).plain();

        assert_eq!(line, "····");
    }

    #[test]
    fn renders_fixed_width() {
        let line = Sparkline::new([0.0, 25.0, 50.0, 100.0])
            .width(4)
            .range(0.0, 100.0)
            .plain();

        assert_eq!(visible_len(&line), 4);
        assert!(line.ends_with(''));
    }

    #[test]
    fn uses_recent_window() {
        let line = Sparkline::new([0.0, 0.0, 100.0]).width(2).plain();

        assert_eq!(visible_len(&line), 2);
        assert!(line.ends_with(''));
    }

    #[test]
    fn view_is_styled() {
        let line = Sparkline::new([1.0]).width(1).view();

        assert_ne!(strip_ansi(&line), line);
    }
}