use std::fmt::Write as _;
use anstyle::Style;
use tabled::builder::Builder;
use tabled::settings::peaker::Priority;
use tabled::settings::{Padding, Width};
use crate::render::Context;
use crate::render::style::{Painter, Palette};
#[derive(Clone, Copy)]
pub enum Paint {
Fixed(Style),
ByValue(fn(&str) -> Style),
ByOther {
source: usize,
pick: fn(&str) -> Style,
},
}
impl std::fmt::Debug for Paint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Fixed(style) => f.debug_tuple("Fixed").field(style).finish(),
Self::ByValue(_) => f.write_str("ByValue(..)"),
Self::ByOther { source, .. } => write!(f, "ByOther({source})"),
}
}
}
impl Paint {
fn style(self, cell: &str, row: &[String]) -> Style {
match self {
Self::Fixed(style) => style,
Self::ByValue(pick) => pick(cell),
Self::ByOther { source, pick } => row.get(source).map_or_else(Style::new, |value| {
if value.is_empty() {
Style::new()
} else {
pick(value)
}
}),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Column {
pub header: &'static str,
pub width: usize,
pub truncate: bool,
pub paint: Paint,
}
impl Column {
#[must_use]
pub const fn new(header: &'static str, width: usize, style: Style) -> Self {
Self {
header,
width,
truncate: true,
paint: Paint::Fixed(style),
}
}
#[must_use]
pub const fn whole(header: &'static str, width: usize, style: Style) -> Self {
Self {
truncate: false,
..Self::new(header, width, style)
}
}
#[must_use]
pub const fn by_value(header: &'static str, width: usize, pick: fn(&str) -> Style) -> Self {
Self {
header,
width,
truncate: true,
paint: Paint::ByValue(pick),
}
}
#[must_use]
pub const fn by_other(
header: &'static str,
width: usize,
source: usize,
pick: fn(&str) -> Style,
) -> Self {
Self {
header,
width,
truncate: true,
paint: Paint::ByOther { source, pick },
}
}
}
#[must_use]
pub fn render(columns: &[Column], rows: &[Vec<String>], ctx: &Context) -> String {
if ctx.is_human() {
human(columns, rows, ctx)
} else {
machine(columns, rows)
}
}
fn machine(columns: &[Column], rows: &[Vec<String>]) -> String {
let mut out = String::with_capacity(rows.len() * 80);
for row in rows {
let mut line = String::with_capacity(80);
let printed = row.len().min(columns.len());
for (index, cell) in row.iter().take(printed).enumerate() {
let Some(column) = columns.get(index) else {
continue;
};
let value = if column.truncate {
truncate(cell, column.width)
} else {
cell.clone()
};
if index + 1 == printed {
line.push_str(&value);
} else {
let _ = write!(
line,
"{value}{} ",
" ".repeat(column.width.saturating_sub(value.chars().count()))
);
}
}
let _ = writeln!(out, "{}", line.trim_end());
}
out
}
fn human(columns: &[Column], rows: &[Vec<String>], ctx: &Context) -> String {
if rows.is_empty() {
return String::new();
}
let paint = ctx.painter();
let mut builder = Builder::with_capacity(rows.len() + 1, columns.len());
builder.push_record(
columns
.iter()
.map(|column| paint.paint(column.header, Palette::label())),
);
for row in rows {
builder.push_record(paint_row(columns, row, paint));
}
let mut table = builder.build();
table
.with(tabled::settings::Style::blank())
.with(Padding::new(0, 2, 0, 0));
table.with(
Width::truncate(ctx.width)
.suffix("…")
.priority(Priority::max(true)),
);
let mut out = String::with_capacity(rows.len() * 96);
for line in table.to_string().lines() {
let _ = writeln!(out, "{}", line.trim_end());
}
out
}
fn paint_row(columns: &[Column], row: &[String], paint: Painter) -> Vec<String> {
row.iter()
.take(columns.len())
.enumerate()
.map(|(index, cell)| match columns.get(index) {
Some(column) => paint.paint(cell, column.paint.style(cell, row)),
None => cell.clone(),
})
.collect()
}
#[must_use]
pub fn tally(shown: usize, total: Option<u64>, next_page: Option<u32>, ctx: &Context) -> String {
let paint = ctx.painter();
let counted = match total {
Some(total) => format!("shown {shown} of {total}"),
None => format!("shown {shown} of unknown total"),
};
let mut out = paint.paint(&counted, Palette::label());
if let Some(page) = next_page {
out.push_str(&paint.paint(&format!(" — next: --page {page}"), Palette::warn()));
}
out.push('\n');
out
}
pub(crate) fn truncate(value: &str, width: usize) -> String {
if value.chars().count() <= width {
return value.to_owned();
}
let mut kept: String = value.chars().take(width.saturating_sub(1)).collect();
kept.push('…');
kept
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{Audience, Format};
fn ctx(audience: Audience) -> Context {
Context {
format: Format::Text,
audience,
description_lines: None,
extra_fields: Vec::new(),
width: 80,
images: false,
inline: crate::render::image::Inline::default(),
}
}
fn columns() -> Vec<Column> {
vec![
Column::whole("KEY", 12, Palette::key()),
Column::new("SUMMARY", 40, Style::new()),
]
}
fn rows() -> Vec<Vec<String>> {
vec![
vec!["PROJ-1".to_owned(), "short".to_owned()],
vec!["PROJ-22".to_owned(), "a longer summary".to_owned()],
]
}
#[test]
fn a_pipe_puts_every_column_at_a_fixed_offset() {
let out = machine(&columns(), &rows());
for (line, row) in out.lines().zip(rows()) {
let summary: String = line.chars().skip(13).collect();
assert_eq!(summary, row[1], "the second column moved");
}
}
#[test]
fn a_pipe_gets_no_trailing_padding() {
let out = machine(&columns(), &rows());
assert!(out.lines().all(|line| !line.ends_with(' ')));
}
#[test]
fn both_forms_carry_the_same_values() {
let piped = machine(&columns(), &rows());
let terminal = human(&columns(), &rows(), &ctx(Audience::Human));
for row in rows() {
for cell in row {
assert!(piped.contains(&cell), "{cell} missing from the pipe form");
assert!(
terminal.contains(&cell),
"{cell} missing from the terminal form"
);
}
}
}
#[test]
fn a_key_is_never_cut() {
let long = vec![vec!["PROJECT-1234567890".to_owned(), "summary".to_owned()]];
assert!(machine(&columns(), &long).contains("PROJECT-1234567890"));
}
#[test]
fn an_over_long_value_is_cut_with_an_ellipsis() {
assert_eq!(truncate("abcdef", 4), "abc…");
assert_eq!(truncate("abc", 4), "abc");
}
#[test]
fn a_terminal_table_stays_inside_the_window() {
let wide = vec![vec!["PROJ-1".to_owned(), "x".repeat(400)]];
let narrow = Context {
width: 40,
..ctx(Audience::Human)
};
let out = human(&columns(), &wide, &narrow);
assert!(
out.lines()
.all(|line| strip_ansi(line).chars().count() <= 40),
"a line ran past the window"
);
}
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
out
}
#[test]
fn the_tally_names_the_next_page_when_there_is_one() {
let ctx = ctx(Audience::Machine);
assert_eq!(
tally(25, Some(340), Some(2), &ctx),
"shown 25 of 340 — next: --page 2\n"
);
assert_eq!(tally(1, Some(1), None, &ctx), "shown 1 of 1\n");
assert_eq!(tally(1, None, None, &ctx), "shown 1 of unknown total\n");
}
}