ascii_table_rs 1.0.2

Elegant ASCII table renderer for Rust CLI and terminal apps
Documentation
use std::fmt;
use strip_ansi_escapes::strip_str;
use unicode_width::UnicodeWidthStr;

const TOP_LEFT: char = '╭';
const TOP_RIGHT: char = '╮';
const BOTTOM_LEFT: char = '╰';
const BOTTOM_RIGHT: char = '╯';
const HORIZONTAL: char = '─';
const VERTICAL: char = '│';

const HEADER_LEFT: char = '├';
const HEADER_RIGHT: char = '┤';
const HEADER_MID: char = '┬';

const ROW_LEFT: char = '├';
const ROW_RIGHT: char = '┤';
const ROW_MID: char = '┼';

const FOOT_MID: char = '┴';

#[derive(Debug, Clone)]
pub enum CellValue {
    Str(String),
    Int(i64),
    Float(f64),
}

impl CellValue {
    pub fn to_string_with_precision(&self, decimal_places: usize) -> String {
        match self {
            CellValue::Str(s) => s.clone(),
            CellValue::Int(i) => i.to_string(),
            CellValue::Float(f) => {
                let factor = 10f64.powi(decimal_places as i32);
                let truncated = (f * factor).trunc() / factor;
                format!("{:.1$}", truncated, decimal_places)
            }
        }
    }
}

impl fmt::Display for CellValue {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CellValue::Str(s) => write!(f, "{}", s),
            CellValue::Int(i) => write!(f, "{}", i),
            CellValue::Float(fl) => {
                let default_precision = 2;
                let factor = 10f64.powi(default_precision as i32);
                let truncated = (fl * factor).trunc() / factor;
                write!(f, "{:.1$}", truncated, default_precision)
            }
        }
    }
}

pub struct AsciiTable {
    title: String,
    headers: Vec<String>,
    rows: Vec<Vec<CellValue>>,
    summary: Option<Vec<CellValue>>,
    decimal_places: usize,
}

impl AsciiTable {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            headers: vec![],
            rows: vec![],
            summary: None,
            decimal_places: 2,
        }
    }

    pub fn headers(&self) -> &[String] {
        &self.headers
    }

    pub fn rows(&self) -> &[Vec<CellValue>] {
        &self.rows
    }

    pub fn summary(&self) -> Option<&[CellValue]> {
        self.summary.as_deref()
    }

    pub fn set_headers(&mut self, headers: Vec<impl Into<String>>) {
        self.headers = headers.into_iter().map(|h| h.into()).collect();
    }

    pub fn add_row(&mut self, row: Vec<CellValue>) {
        self.rows.push(row);
    }

    pub fn set_summary(&mut self, summary: Vec<CellValue>) {
        self.summary = Some(summary);
    }

    pub fn set_decimal_places(&mut self, places: usize) {
        self.decimal_places = places;
    }

    pub fn render_to_string(&self) -> String {
        let mut output = String::new();
        let mut col_widths = vec![0; self.headers.len()];

        for (i, header) in self.headers.iter().enumerate() {
            col_widths[i] = get_str_width_safe(header.as_str());
        }

        for row in &self.rows {
            for (i, cell) in row.iter().enumerate() {
                let cell_str = cell.to_string_with_precision(self.decimal_places);
                col_widths[i] = col_widths[i].max(get_str_width_safe(cell_str.as_str()));
            }
        }

        if let Some(summary) = &self.summary {
            for (i, cell) in summary.iter().enumerate() {
                let cell_str = cell.to_string_with_precision(self.decimal_places);
                col_widths[i] = col_widths[i].max(get_str_width_safe(cell_str.as_str()));
            }
        }

        let total_width: usize =
            col_widths.iter().map(|w| w + 2).sum::<usize>() + (col_widths.len() - 1);

        output += &format!(
            "{}{}{}\n",
            TOP_LEFT,
            HORIZONTAL.to_string().repeat(total_width),
            TOP_RIGHT
        );
        output += &format!(
            "{}{:^width$}{}\n",
            VERTICAL,
            self.title,
            VERTICAL,
            width = total_width
        );
        output += &format_separator(&col_widths, HEADER_LEFT, HEADER_MID, HEADER_RIGHT);
        output += &format_row(&self.headers, &col_widths);
        output += &format_separator(&col_widths, ROW_LEFT, ROW_MID, ROW_RIGHT);

        for row in &self.rows {
            let cells: Vec<String> = row
                .iter()
                .map(|c| c.to_string_with_precision(self.decimal_places))
                .collect();
            output += &format_row(&cells, &col_widths);
        }

        if let Some(summary) = &self.summary {
            output += &format_separator(&col_widths, ROW_LEFT, ROW_MID, ROW_RIGHT);
            let cells: Vec<String> = summary
                .iter()
                .map(|c| c.to_string_with_precision(self.decimal_places))
                .collect();
            output += &format_row(&cells, &col_widths);
        }

        output += &format_separator(&col_widths, BOTTOM_LEFT, FOOT_MID, BOTTOM_RIGHT);
        output
    }

    pub fn render(&self) {
        print!("{}", self.render_to_string());
    }
}

fn format_separator(widths: &[usize], left: char, mid: char, right: char) -> String {
    let mut line = String::new();
    line += &left.to_string();
    for (i, w) in widths.iter().enumerate() {
        line += &HORIZONTAL.to_string().repeat(*w + 2);
        if i < widths.len() - 1 {
            line += &mid.to_string();
        }
    }
    line += &right.to_string();
    line += "\n";
    line
}

fn format_row(values: &[String], widths: &[usize]) -> String {
    let mut line = String::new();
    line += &VERTICAL.to_string();
    for (value, width) in values.iter().zip(widths.iter()) {
        let content_width = get_str_width_safe(value.as_str());
        let padding = width.saturating_sub(content_width);
        line += &format!(
            " {:left$}{:right$} {}",
            value,
            "",
            VERTICAL,
            left = content_width,
            right = padding
        );
    }
    line += "\n";
    line
}

fn get_str_width_safe(txt: &str) -> usize {
    let stripped = strip_str(txt);

    UnicodeWidthStr::width(stripped.as_str())
}