a3s-tui 0.1.9

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

const MAX_METER_WIDTH: usize = u16::MAX as usize;

#[derive(Debug, Clone)]
pub struct Meter {
    label: String,
    value: f64,
    max: f64,
    width: usize,
    fg: Color,
    empty_fg: Color,
    fill: char,
    empty: char,
    show_value: bool,
}

impl Meter {
    pub fn new(value: f64) -> Self {
        Self {
            label: String::new(),
            value,
            max: 100.0,
            width: 32,
            fg: Color::Cyan,
            empty_fg: Color::BrightBlack,
            fill: '',
            empty: '',
            show_value: true,
        }
    }

    pub fn label(mut self, label: impl Into<String>) -> Self {
        self.label = label.into();
        self
    }

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

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

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

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

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

    pub fn show_value(mut self, show: bool) -> Self {
        self.show_value = show;
        self
    }

    pub fn view(&self) -> String {
        let value = if self.value.is_finite() {
            self.value
        } else {
            0.0
        };
        let max = Self::normalize_max(self.max);
        let ratio = (value / max).clamp(0.0, 1.0);
        let value_label = if !self.show_value {
            String::new()
        } else if (max - 100.0).abs() < f64::EPSILON {
            format!("{value:>5.1}%")
        } else {
            format!("{value:>6.1}")
        };
        let prefix = match (self.label.is_empty(), self.show_value) {
            (true, true) => format!("{value_label} "),
            (true, false) => String::new(),
            (false, true) => format!("{} {value_label} ", self.label),
            (false, false) => format!("{} ", self.label),
        };

        let prefix_width = visible_len(&prefix).min(self.width);
        let bar_width = self.width.saturating_sub(prefix_width);
        if bar_width == 0 {
            return truncate_visible(&prefix, self.width);
        }

        let filled = ((bar_width as f64) * ratio).round() as usize;
        let filled = filled.min(bar_width);
        let empty = bar_width.saturating_sub(filled);
        let bar = format!(
            "{}{}",
            Style::new()
                .fg(self.fg)
                .render(&repeat_visible_char(self.fill, filled)),
            Style::new()
                .fg(self.empty_fg)
                .render(&repeat_visible_char(self.empty, empty))
        );

        format!("{prefix}{bar}")
    }

    pub fn plain(&self) -> String {
        crate::style::strip_ansi(&self.view())
    }

    fn normalize_max(max: f64) -> f64 {
        if max.is_finite() {
            max.max(f64::EPSILON)
        } else {
            f64::EPSILON
        }
    }
}

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

    #[test]
    fn renders_fixed_width() {
        let line = Meter::new(25.0).label("CPU").width(20).view();

        assert_eq!(visible_len(&line), 20);
        assert!(strip_ansi(&line).contains("25.0%"));
    }

    #[test]
    fn clamps_to_full_bar() {
        let line = Meter::new(200.0).label("MEM").width(16).plain();

        assert_eq!(visible_len(&line), 16);
        assert!(!line.contains(''));
    }

    #[test]
    fn truncates_when_prefix_exceeds_width() {
        let line = Meter::new(1.0).label("very-long-label").width(8).plain();

        assert_eq!(visible_len(&line), 8);
    }

    #[test]
    fn can_render_bar_without_value_label() {
        let line = Meter::new(50.0)
            .width(6)
            .glyphs('', '')
            .show_value(false)
            .plain();

        assert_eq!(visible_len(&line), 6);
        assert_eq!(line, "▰▰▰▱▱▱");
    }

    #[test]
    fn non_finite_max_is_treated_as_minimum_positive_range() {
        let line = Meter::new(1.0).max(f64::NAN).width(12).plain();

        assert_eq!(visible_len(&line), 12);
        assert!(!line.contains(''));
    }

    #[test]
    fn custom_wide_glyphs_fill_bar_by_display_width() {
        let line = Meter::new(50.0)
            .label("CPU")
            .width(16)
            .glyphs('', '·')
            .view();
        let plain = strip_ansi(&line);

        assert_eq!(visible_len(&line), 16);
        assert_eq!(visible_len(&plain), 16);
        assert!(plain.contains(""));
        assert!(plain.ends_with("··"));
    }

    #[test]
    fn custom_zero_width_glyphs_fall_back_to_spaces() {
        let line = Meter::new(50.0)
            .width(12)
            .glyphs('\u{301}', '\u{301}')
            .plain();

        assert_eq!(visible_len(&line), 12);
        assert!(line.ends_with("     "));
    }

    #[test]
    fn oversized_width_is_clamped() {
        let meter = Meter::new(50.0).width(usize::MAX);
        let line = meter.plain();

        assert_eq!(meter.width, MAX_METER_WIDTH);
        assert_eq!(visible_len(&line), MAX_METER_WIDTH);
    }
}