rtimelog 0.51.0

System for tracking time in a text-log-based format.
Documentation
//! Define color handling for charts.

use std::collections::HashMap;

use crate::chart::TagPercent;

// Built from Colorbrewer 2.0, with help from Blonde
const COLORS: [&str; 8] = [
    "#1f78b4", "#a6cee3", "#33a02c", "#b2df8a", "#aa3133", "#fb9a99", "#ff7f00", "#fdbf6f"
];
const LAST_COLOR: usize = COLORS.len() - 1;

/// Iterator over the available colors.
#[derive(Clone)]
pub struct ColorIter<'a> {
    // Current index into the colors
    idx:    usize,
    // Slice holding the colors
    colors: &'a [&'static str],
    // Index of the last item in the color slice
    last:   usize
}

impl<'a> ColorIter<'a> {
    /// Create a [`ColorIter`] based on the supplied slice of a color container.
    pub fn new(colors: &'a [&'static str]) -> Self {
        Self { idx: 0, colors, last: colors.len() - 1 }
    }

    /// Convert a slice of [`TagPercent`] into a Vector of [`TagPercent`]s limited
    /// to the number of colors. If there are more percent entries than colors, the
    /// remaining items are combined with the entry for the last color.
    pub fn limit_percents(&self, percents: &[TagPercent], other: &str) -> Vec<TagPercent> {
        if percents.len() <= self.colors.len() {
            percents.to_vec()
        }
        else {
            let len = self.colors.len() - 1;
            let percent: f32 = percents.iter().skip(len).map(TagPercent::percent_val).sum();
            let mut percents: Vec<TagPercent> = percents.iter().take(len).cloned().collect();

            // Since the percents are all calculated from the sum, there should be none that
            // fail to new.
            if let Some(perc) = TagPercent::new(other, percent) {
                percents.push(perc);
            }
            percents
        }
    }
}

impl<'a> Iterator for ColorIter<'a> {
    type Item = &'static str;

    /// Return the next color, repeating the last as long as needed.
    fn next(&mut self) -> Option<Self::Item> {
        let idx = self.idx;
        if idx < self.last {
            self.idx += 1;
        }
        Some(self.colors[idx])
    }
}

impl<'a> Default for ColorIter<'a> {
    /// Create a [`ColorIter`] based on the default set of colors
    fn default() -> Self { Self::new(&COLORS) }
}

/// A mapping of labels to colors.
pub struct ColorMap(HashMap<String, &'static str>);

impl ColorMap {
    /// Create a [`ColorMap`] from the supplied slice of [`TagPercent`] with the default colors.
    pub fn new(percents: &[TagPercent]) -> Self {
        Self::new_with_colors(percents, ColorIter::default())
    }

    /// Create a [`ColorMap`] from the supplied slice of [`TagPercent`] and [`ColorIter`].
    pub fn new_with_colors(percents: &[TagPercent], colors: ColorIter) -> Self {
        Self(
            percents
                .iter()
                .zip(colors)
                .map(|(tp, clr)| (tp.label().to_string(), clr))
                .collect()
        )
    }

    /// Return the color associated with the supplied label.
    pub fn get(&self, label: &str) -> Option<&'static str> { self.0.get(label).copied() }

    /// Declare a label for the final color.
    pub fn set_default(&mut self, label: &str) {
        self.0.insert(label.to_string(), COLORS[LAST_COLOR]);
    }

    /// Declare a label for the final color.
    pub fn set_default_to_color(&mut self, label: &str, clr: &'static str) {
        self.0.insert(label.to_string(), clr);
    }
}

#[cfg(test)]
mod tests {
    use spectral::prelude::*;

    use super::*;

    #[test]
    fn test_new_iter() {
        let mut iter = ColorIter::new(&COLORS);
        assert_that!(iter.next()).is_some();
    }

    #[test]
    fn test_color_iter_base() {
        for (clr, expect) in ColorIter::default().zip(&COLORS) {
            assert_that!(clr).is_equal_to(expect);
        }
    }

    #[test]
    fn test_color_iter_tail() {
        let mut iter = ColorIter::default().skip(COLORS.len());
        for _ in 0..10 {
            assert_that!(iter.next()).contains(COLORS[LAST_COLOR]);
        }
    }

    #[test]
    fn test_new() {
        let colors = [
            "black", "brown", "red", "orange", "yellow", "green", "blue", "violet", "grey", "white"
        ];
        let iter = ColorIter::new(&colors);

        for (actual, expect) in iter.zip(&colors) {
            assert_that!(actual).is_equal_to(expect);
        }
    }

    #[test]
    fn color_map_few() {
        #[rustfmt::skip]
        let percents = [
            TagPercent::new("david",   40.0).unwrap(),
            TagPercent::new("connie",  20.0).unwrap(),
            TagPercent::new("mark",    10.0).unwrap(),
            TagPercent::new("kirsten", 10.0).unwrap(),
            TagPercent::new("fred",     5.0).unwrap(),
            TagPercent::new("bianca",   5.0).unwrap(),
        ];

        let cmap = ColorMap::new(&percents);
        for (label, clr) in percents
            .iter()
            .map(|tp| tp.label())
            .zip(ColorIter::default())
        {
            assert_that!(cmap.get(label)).contains(clr);
        }
    }

    #[test]
    fn color_map_with_default() {
        #[rustfmt::skip]
        let percents = [
            TagPercent::new("david",   40.0).unwrap(),
            TagPercent::new("connie",  20.0).unwrap(),
            TagPercent::new("mark",    10.0).unwrap(),
            TagPercent::new("kirsten", 10.0).unwrap(),
            TagPercent::new("fred",     5.0).unwrap(),
            TagPercent::new("bianca",   5.0).unwrap(),
        ];

        let mut cmap = ColorMap::new(&percents);
        assert_that!(cmap.get("Other"))
            .named("no default")
            .is_none();

        cmap.set_default("Other");
        assert_that!(cmap.get("Other"))
            .named("has default")
            .contains(COLORS[LAST_COLOR]);
    }

    #[test]
    fn color_map_many() {
        #[rustfmt::skip]
        let percents = [
            TagPercent::new("david",   40.0).unwrap(),
            TagPercent::new("connie",  20.0).unwrap(),
            TagPercent::new("mark",    10.0).unwrap(),
            TagPercent::new("kirsten", 10.0).unwrap(),
            TagPercent::new("fred",     5.0).unwrap(),
            TagPercent::new("bianca",   5.0).unwrap(),
            TagPercent::new("aramis",   1.0).unwrap(),
            TagPercent::new("bryan",    1.0).unwrap(),
            TagPercent::new("serpent",  1.0).unwrap(),
            TagPercent::new("mobile",   1.0).unwrap(),
        ];

        let cmap = ColorMap::new(&percents);
        for (label, clr) in percents
            .iter()
            .map(|tp| tp.label())
            .zip(ColorIter::default())
        {
            assert_that!(cmap.get(label)).contains(clr);
        }
    }

    #[test]
    fn limit_percents_few() {
        #[rustfmt::skip]
        let percents = [
            TagPercent::new("david",   40.0).unwrap(),
            TagPercent::new("connie",  20.0).unwrap(),
            TagPercent::new("mark",    10.0).unwrap(),
            TagPercent::new("kirsten", 10.0).unwrap(),
            TagPercent::new("fred",     5.0).unwrap(),
            TagPercent::new("bianca",   5.0).unwrap(),
        ];
        let colors = ColorIter::default();

        assert_that!(colors.limit_percents(&percents, "Other").iter())
            .equals_iterator(&percents.iter());
    }

    #[test]
    fn limit_percents_many() {
        #[rustfmt::skip]
        let percents = [
            TagPercent::new("david",   40.0).unwrap(),
            TagPercent::new("connie",  20.0).unwrap(),
            TagPercent::new("mark",    10.0).unwrap(),
            TagPercent::new("kirsten", 10.0).unwrap(),
            TagPercent::new("fred",     5.0).unwrap(),
            TagPercent::new("bianca",   5.0).unwrap(),
            TagPercent::new("aramis",   1.0).unwrap(),
            TagPercent::new("bryan",    1.0).unwrap(),
            TagPercent::new("serpent",  1.0).unwrap(),
            TagPercent::new("mobile",   1.0).unwrap(),
        ];

        #[rustfmt::skip]
        let expected = [
            TagPercent::new("david",   40.0).unwrap(),
            TagPercent::new("connie",  20.0).unwrap(),
            TagPercent::new("mark",    10.0).unwrap(),
            TagPercent::new("kirsten", 10.0).unwrap(),
            TagPercent::new("fred",     5.0).unwrap(),
            TagPercent::new("bianca",   5.0).unwrap(),
            TagPercent::new("aramis",   1.0).unwrap(),
            TagPercent::new("Other",    3.0).unwrap(),
        ];

        let colors = ColorIter::default();
        assert_that!(colors.limit_percents(&percents, "Other").iter())
            .equals_iterator(&expected.iter());
    }
}