use owo_colors::{OwoColorize, Style};
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<Cell>>,
}
pub struct Cell {
text: String,
style: Option<Style>,
}
impl Cell {
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
style: None,
}
}
pub fn styled(text: impl Into<String>, style: Style) -> Self {
Self {
text: text.into(),
style: Some(style),
}
}
}
impl Table {
pub fn new(headers: &[&str]) -> Self {
Self {
headers: headers.iter().map(|header| (*header).to_owned()).collect(),
rows: Vec::new(),
}
}
pub fn push(&mut self, row: Vec<Cell>) {
self.rows.push(row);
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
pub fn print(&self) {
if self.rows.is_empty() {
return;
}
let widths = self.widths();
let header: Vec<String> = self
.headers
.iter()
.enumerate()
.map(|(index, header)| pad(&header.to_uppercase(), widths[index]))
.collect();
println!("{}", header.join(" ").style(Style::new().bright_black()));
for row in &self.rows {
let cells: Vec<String> = row
.iter()
.enumerate()
.map(|(index, cell)| {
let padded = pad(&cell.text, widths.get(index).copied().unwrap_or(0));
match cell.style {
Some(style) => format!("{}", padded.style(style)),
None => padded,
}
})
.collect();
println!("{}", cells.join(" ").trim_end());
}
}
fn widths(&self) -> Vec<usize> {
let mut widths: Vec<usize> = self
.headers
.iter()
.map(|header| header.chars().count())
.collect();
for row in &self.rows {
for (index, cell) in row.iter().enumerate() {
let width = display_width(&cell.text);
match widths.get_mut(index) {
Some(current) => *current = (*current).max(width),
None => widths.push(width),
}
}
}
widths
}
}
fn pad(text: &str, width: usize) -> String {
let current = display_width(text);
if current >= width {
return text.to_owned();
}
format!("{text}{}", " ".repeat(width - current))
}
pub fn display_width(text: &str) -> usize {
let mut width = 0;
let mut characters = text.chars();
while let Some(character) = characters.next() {
if character == '\u{1b}' {
for next in characters.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
continue;
}
width += 1;
}
width
}
pub fn truncate(text: &str, width: usize) -> String {
if text.chars().count() <= width {
return text.to_owned();
}
if width <= 1 {
return "…".to_owned();
}
let kept: String = text.chars().take(width - 1).collect();
format!("{kept}…")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_measure_plain_text_by_its_characters() {
assert_eq!(display_width("hola"), 4);
}
#[test]
fn it_should_ignore_colour_escapes_when_measuring() {
assert_eq!(display_width("\u{1b}[32mhola\u{1b}[0m"), 4);
}
#[test]
fn it_should_measure_accented_characters_as_one_each() {
assert_eq!(display_width("día"), 3);
}
#[test]
fn it_should_leave_short_text_alone_when_truncating() {
assert_eq!(truncate("hola", 10), "hola");
}
#[test]
fn it_should_end_truncated_text_with_an_ellipsis() {
assert_eq!(truncate("abcdefgh", 4), "abc…");
}
#[test]
fn it_should_pad_to_the_requested_width() {
assert_eq!(pad("ab", 5), "ab ");
}
#[test]
fn it_should_not_shorten_text_wider_than_the_column() {
assert_eq!(pad("abcdef", 3), "abcdef");
}
#[test]
fn it_should_report_a_table_with_no_rows_as_empty() {
assert!(Table::new(&["a"]).is_empty());
}
}