use crate::escape::terminal_text;
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Alignment {
Left,
Right,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Options {
pub column_gap: usize,
pub header_separator: bool,
}
#[must_use]
pub fn render<const N: usize>(
headers: &[impl AsRef<str>; N],
rows: &[[impl AsRef<str>; N]],
alignments: &[Alignment; N],
options: Options,
) -> String {
if N == 0 {
return String::new();
}
let headers = headers.each_ref().map(|cell| terminal_text(cell.as_ref()));
let rows = rows
.iter()
.map(|row| row.each_ref().map(|cell| terminal_text(cell.as_ref())))
.collect::<Vec<_>>();
let widths = std::array::from_fn(|index| {
rows.iter().fold(
UnicodeWidthStr::width(headers[index].as_str()),
|width, row| width.max(UnicodeWidthStr::width(row[index].as_str())),
)
});
let gap = " ".repeat(options.column_gap);
let mut lines = vec![render_row(&headers, &widths, alignments, &gap)];
if options.header_separator {
lines.push(
widths
.iter()
.map(|width| "-".repeat(*width))
.collect::<Vec<_>>()
.join(&gap),
);
}
lines.extend(
rows.iter()
.map(|row| render_row(row, &widths, alignments, &gap)),
);
lines.join("\n")
}
fn render_row<const N: usize>(
row: &[String; N],
widths: &[usize; N],
alignments: &[Alignment; N],
gap: &str,
) -> String {
row.iter()
.zip(widths)
.zip(alignments)
.map(|((value, width), alignment)| {
let padding = " ".repeat(width.saturating_sub(UnicodeWidthStr::width(value.as_str())));
match alignment {
Alignment::Left => format!("{value}{padding}"),
Alignment::Right => format!("{padding}{value}"),
}
})
.collect::<Vec<_>>()
.join(gap)
.trim_end()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
const OPTIONS: Options = Options {
column_gap: 2,
header_separator: true,
};
#[test]
fn wide_and_combining_cells_align_in_display_columns() {
assert_eq!(
render(
&["NAME", "N"],
&[["界", "7"], ["e\u{301}", "123"]],
&[Alignment::Left, Alignment::Right],
OPTIONS
),
"NAME N\n---- ---\n界 7\ne\u{301} 123"
);
}
#[test]
fn escaped_headers_and_cells_are_measured_and_stay_on_their_rows() {
let text = render(
&["H\n"],
&[["x\x1b[2J\t\u{2029}"]],
&[Alignment::Left],
OPTIONS,
);
assert_eq!(text.lines().count(), 3);
assert!(text.starts_with("H\\n\n"));
assert!(text.ends_with("x\\u{1b}[2J\\t\\u{2029}"));
assert!(!text.contains('\x1b'));
}
#[test]
fn explicit_options_and_empty_tables_have_no_hidden_defaults() {
assert_eq!(
render(
&["A", "B"],
&[["1", "2"]],
&[Alignment::Left; 2],
Options {
column_gap: 0,
header_separator: false
}
),
"AB\n12"
);
assert_eq!(
render(&["A"], &[] as &[[&str; 1]], &[Alignment::Left], OPTIONS),
"A\n-"
);
assert_eq!(
render(&[] as &[&str; 0], &[] as &[[&str; 0]], &[], OPTIONS),
""
);
}
}