use std::fmt::Write as _;
use unicode_width::UnicodeWidthChar;
const RESET: &str = "\u{1b}[0m";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Align {
Left,
Right,
}
#[derive(Debug, Clone)]
pub(crate) struct Column {
pub heading: String,
pub align: Align,
pub min_width: usize,
pub max_width: Option<usize>,
pub drop_order: u8,
pub flexible: bool,
}
impl Column {
fn new(heading: impl Into<String>, align: Align) -> Self {
Self {
heading: heading.into(),
align,
min_width: 3,
max_width: None,
drop_order: 0,
flexible: false,
}
}
pub(crate) fn left(heading: impl Into<String>) -> Self {
Self::new(heading, Align::Left)
}
pub(crate) fn right(heading: impl Into<String>) -> Self {
Self::new(heading, Align::Right)
}
pub(crate) const fn drop_order(mut self, drop_order: u8) -> Self {
self.drop_order = drop_order;
self
}
pub(crate) const fn shrinks_to(mut self, min_width: usize) -> Self {
self.flexible = true;
self.min_width = min_width;
self
}
pub(crate) const fn caps_at(mut self, width: usize) -> Self {
self.max_width = Some(width);
self
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Table {
columns: Vec<Column>,
rows: Vec<Vec<String>>,
gap: usize,
}
impl Table {
pub(crate) fn new(columns: Vec<Column>) -> Self {
Self {
columns,
rows: Vec::new(),
gap: 2,
}
}
pub(crate) fn push(&mut self, row: Vec<String>) {
self.rows.push(row);
}
pub(crate) fn render(&self, with_heading: bool, width: usize) -> String {
let plan = self.plan(with_heading, width);
let mut out = String::new();
if with_heading {
let heading: Vec<&str> = self.columns.iter().map(|c| c.heading.as_str()).collect();
self.write_row(&mut out, &heading, &plan);
}
for row in &self.rows {
let cells: Vec<&str> = row.iter().map(String::as_str).collect();
self.write_row(&mut out, &cells, &plan);
}
out
}
fn plan(&self, with_heading: bool, width: usize) -> Vec<Option<usize>> {
let mut widths: Vec<Option<usize>> = self
.columns
.iter()
.enumerate()
.map(|(index, column)| {
let heading = if with_heading {
display_width(&column.heading)
} else {
0
};
let widest = self
.rows
.iter()
.filter_map(|row| row.get(index))
.map(|cell| display_width(cell))
.max()
.unwrap_or(0)
.max(heading);
Some(column.max_width.map_or(widest, |cap| widest.min(cap)))
})
.collect();
if width == 0 {
return widths;
}
let mut order: Vec<usize> = (0..self.columns.len()).collect();
order.sort_by_key(|index| self.columns.get(*index).map_or(0, |c| c.drop_order));
for index in order.into_iter().rev() {
if self.min_total_width(&widths) <= width {
break;
}
if self.columns.get(index).is_some_and(|c| c.drop_order > 0)
&& let Some(slot) = widths.get_mut(index)
{
*slot = None;
}
}
for pass in [true, false] {
loop {
if self.total_width(&widths) <= width {
return widths;
}
let mut shrunk = false;
for (index, column) in self.columns.iter().enumerate() {
if pass && !column.flexible {
continue;
}
if self.total_width(&widths) <= width {
return widths;
}
if let Some(Some(width)) = widths.get_mut(index)
&& *width > column.min_width
{
*width -= 1;
shrunk = true;
}
}
if !shrunk {
break;
}
}
}
widths
}
fn total_width(&self, widths: &[Option<usize>]) -> usize {
let shown: Vec<usize> = widths.iter().flatten().copied().collect();
shown.iter().sum::<usize>() + shown.len().saturating_sub(1) * self.gap
}
fn min_total_width(&self, widths: &[Option<usize>]) -> usize {
let mut sum: usize = 0;
let mut shown: usize = 0;
for (index, width) in widths.iter().enumerate() {
if width.is_some() {
shown += 1;
sum += self.columns.get(index).map_or(3, |c| c.min_width);
}
}
sum + shown.saturating_sub(1) * self.gap
}
fn write_row(&self, out: &mut String, cells: &[&str], widths: &[Option<usize>]) {
let last_shown = widths
.iter()
.enumerate()
.filter_map(|(index, width)| width.map(|_| index))
.next_back();
for (index, column) in self.columns.iter().enumerate() {
let Some(Some(width)) = widths.get(index) else {
continue;
};
let raw = cells.get(index).copied().unwrap_or("");
let cell = truncate(raw, *width);
let padding = width.saturating_sub(display_width(&cell));
match column.align {
Align::Left => {
let _ = write!(out, "{cell}");
if Some(index) != last_shown {
let _ = write!(out, "{:padding$}", "");
}
}
Align::Right => {
let _ = write!(out, "{:padding$}{cell}", "");
}
}
if Some(index) != last_shown {
let _ = write!(out, "{:gap$}", "", gap = self.gap);
}
}
out.push('\n');
}
}
#[must_use]
pub(crate) fn display_width(text: &str) -> usize {
let mut width = 0;
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
continue;
}
width += ch.width().unwrap_or(0);
}
width
}
#[must_use]
pub(crate) fn truncate(text: &str, width: usize) -> String {
if display_width(text) <= width {
return text.to_owned();
}
if width == 0 {
return String::new();
}
let styled = text.contains('\u{1b}');
let budget = width.saturating_sub(1);
let mut used = 0;
let mut out = String::new();
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
out.push(ch);
for next in chars.by_ref() {
out.push(next);
if next.is_ascii_alphabetic() {
break;
}
}
continue;
}
let ch_width = ch.width().unwrap_or(0);
if used + ch_width > budget {
break;
}
used += ch_width;
out.push(ch);
}
out.push('…');
if styled {
out.push_str(RESET);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const CYAN: &str = "\u{1b}[36m";
fn paint(text: &str) -> String {
format!("{CYAN}{text}{RESET}")
}
#[test]
fn colour_codes_do_not_count_toward_width() {
assert_eq!(display_width("com"), 3);
assert_eq!(display_width(&paint("com")), 3);
assert_eq!(display_width(&paint("")), 0);
assert_eq!(display_width("日本"), 4);
assert_eq!(display_width(&paint("日本")), 4);
}
#[test]
fn a_column_stays_aligned_when_only_some_cells_are_styled() {
let mut table = Table::new(vec![Column::left("EXT"), Column::right("RANK")]);
table.push(vec![paint("com"), "1".to_owned()]);
table.push(vec!["averylongextension".to_owned(), "42".to_owned()]);
let rendered = table.render(true, 0);
let widths: Vec<usize> = rendered.lines().map(display_width).collect();
assert!(
widths.windows(2).all(|pair| pair[0] == pair[1]),
"mixed styling broke alignment: {widths:?}"
);
}
#[test]
fn a_narrow_terminal_drops_the_least_useful_column_first() {
let mut table = Table::new(vec![
Column::left("EXT"),
Column::right("RANK").drop_order(2),
Column::left("NOTES").drop_order(1).shrinks_to(5),
]);
table.push(vec![
"com".to_owned(),
"1".to_owned(),
"a fairly long note here".to_owned(),
]);
let wide = table.render(true, 0);
assert!(wide.contains("RANK") && wide.contains("NOTES"));
let narrow = table.render(true, 14);
assert!(narrow.contains("EXT"), "the first column must survive");
assert!(
!narrow.contains("RANK"),
"the highest drop order should go first:\n{narrow}"
);
}
#[test]
fn every_rendered_line_fits_the_given_width() {
let mut table = Table::new(vec![
Column::left("EXTENSION"),
Column::right("RANK").drop_order(2),
Column::left("USED FOR").drop_order(1).shrinks_to(6),
]);
for name in ["com", "averylongextensionname", "io"] {
table.push(vec![
paint(name),
"1".to_owned(),
"business, technology, shopping, media".to_owned(),
]);
}
for width in [20, 30, 40, 60, 100] {
let rendered = table.render(true, width);
for line in rendered.lines() {
assert!(
display_width(line) <= width,
"line of {} exceeded {width}:\n{line}",
display_width(line)
);
}
}
}
#[test]
fn a_flexible_column_gives_up_space_before_a_fixed_one() {
let mut table = Table::new(vec![
Column::left("FIXED"),
Column::left("FLEX").shrinks_to(4),
]);
table.push(vec!["abcdefgh".to_owned(), "wxyzwxyzwxyz".to_owned()]);
let rendered = table.render(false, 16);
let first = rendered.lines().next().unwrap_or("");
assert!(
first.starts_with("abcdefgh"),
"fixed column shrank: {first}"
);
assert!(display_width(first) <= 16);
}
#[test]
fn truncation_never_splits_an_escape_sequence() {
let cut = truncate(&paint("averylongvalue"), 6);
assert!(display_width(&cut) <= 6);
assert!(cut.ends_with(RESET), "style was left open: {cut:?}");
}
#[test]
fn truncation_lands_on_a_character_boundary() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello", 5), "hello");
assert_eq!(truncate("hello", 4), "hel…");
assert_eq!(truncate("hello", 1), "…");
assert_eq!(truncate("hello", 0), "");
}
#[test]
fn truncation_measures_wide_characters_by_display_width() {
let cut = truncate("日本語のドメイン", 7);
assert!(display_width(&cut) <= 7, "{cut} is {}", display_width(&cut));
assert!(cut.ends_with('…'));
}
#[test]
fn a_zero_width_means_do_not_fit() {
let mut table = Table::new(vec![Column::left("A"), Column::left("B").drop_order(1)]);
table.push(vec!["x".repeat(50), "y".repeat(50)]);
let rendered = table.render(false, 0);
assert!(display_width(rendered.lines().next().unwrap_or("")) > 100);
}
#[test]
fn a_column_that_may_never_drop_survives_an_impossible_width() {
let mut table = Table::new(vec![
Column::left("KEEP"),
Column::left("GO").drop_order(1).shrinks_to(4),
]);
table.push(vec!["value".to_owned(), "other".to_owned()]);
let rendered = table.render(true, 2);
assert!(!rendered.trim().is_empty());
}
#[test]
fn an_empty_table_renders_only_its_heading() {
let table = Table::new(vec![Column::left("EXT")]);
assert_eq!(table.render(true, 0).trim(), "EXT");
assert_eq!(table.render(false, 0), "");
}
#[test]
fn a_short_row_is_padded_rather_than_panicking() {
let mut table = Table::new(vec![Column::left("A"), Column::left("B")]);
table.push(vec!["only".to_owned()]);
assert!(table.render(false, 0).starts_with("only"));
}
}