use crate::{
Align, ColSpec, FancyTable, FancyTableBuilder, FancyTableOpts, Layout, Overflow, Separator,
TitleAlign, TitleSpec,
charset::Charset,
juststr::{JustedString, Justify},
};
const DEFAULT_COLUMN_WIDTH: usize = 10;
impl Default for FancyTableOpts {
fn default() -> Self {
Self {
title_align: TitleAlign::LeftOffset(4),
charset: Charset::Modern,
headers_separator: Some(Separator::Double),
rows_separator: None,
max_lines: 3,
}
}
}
impl<'a, T: AsRef<str>> FancyTableBuilder<'a, T> {
fn new(opts: FancyTableOpts) -> Self {
Self {
headers: Vec::new(),
columns: Vec::new(),
padding: 1,
width: 80,
charset: opts.charset,
rows_separator: opts.rows_separator,
headers_separator: opts.headers_separator,
max_lines: opts.max_lines,
title: None,
title_align: opts.title_align,
}
}
fn add_column_spec(
mut self,
width: usize,
max_lines: usize,
layout: Layout,
align: Align,
overflow: Overflow,
) -> Self {
self.columns.push(ColSpec {
width,
layout,
align,
overflow,
max_lines,
});
self
}
pub fn add_column(
mut self,
header: Option<T>,
layout: Layout,
align: Align,
overflow: Overflow,
max_lines: usize,
) -> Self {
let len = match layout {
Layout::Fixed(f) => f,
_ => header
.as_ref()
.map(|h| h.as_ref().chars().count())
.unwrap_or(DEFAULT_COLUMN_WIDTH),
};
if let Some(header) = header {
self.headers.push(header);
}
self.add_column_spec(len, max_lines, layout, align, overflow)
}
pub fn add_column_named(self, header: T, layout: Layout) -> Self {
self.add_column_named_with_align(header, layout, Align::Left)
}
pub fn add_column_named_wrapping(self, header: T, layout: Layout) -> Self {
self.add_column_named_wrapping_with_align(header, layout, Align::Left)
}
pub fn add_column_named_with_align(mut self, header: T, layout: Layout, align: Align) -> Self {
let len = header.as_ref().len();
let max_lines = self.max_lines;
self.headers.push(header);
self.add_column_spec(len, max_lines, layout, align, Overflow::Truncate)
}
pub fn add_column_named_wrapping_with_align(
mut self,
header: T,
layout: Layout,
align: Align,
) -> Self {
let len = header.as_ref().len();
let max_lines = self.max_lines;
self.headers.push(header);
self.add_column_spec(len, max_lines, layout, align, Overflow::Wrap)
}
pub fn add_title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
pub fn add_title_with_align(mut self, title: &'a str, align: TitleAlign) -> Self {
self.title_align = align;
self.add_title(title)
}
pub fn padding(mut self, padding: usize) -> Self {
self.padding = padding;
self
}
pub fn hseparator(mut self, separator: Option<Separator>) -> Self {
self.headers_separator = separator;
self
}
pub fn rseparator(mut self, separator: Option<Separator>) -> Self {
self.rows_separator = separator;
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = width;
self
}
pub fn build(self) -> FancyTable<'a, T> {
let title = self.title.map(|t| TitleSpec {
title: t,
align: self.title_align,
});
let mut table = FancyTable {
width: self.width,
chars: self.charset.get_chars(),
rows_separator: self.rows_separator,
headers_separator: self.headers_separator,
padding: self.padding,
headers: self.headers,
columns: self.columns,
title,
};
table.recalculate(self.width);
table
}
}
impl<'a, T: AsRef<str>> FancyTable<'a, T> {
pub fn create(opts: FancyTableOpts) -> FancyTableBuilder<'a, T> {
FancyTableBuilder::new(opts)
}
fn recalculate(&mut self, table_width: usize) {
let cols_count = self.columns.len();
let mut min_table_width = 0;
for (i, spec) in self.columns.iter_mut().enumerate() {
let column_width = match spec.layout {
Layout::Fixed(width) => width,
Layout::Slim | Layout::Expandable(_) => self
.headers
.get(i)
.map(|h| h.as_ref().len() + (2 * self.padding))
.unwrap_or(0),
};
spec.width = column_width;
min_table_width += spec.width;
}
min_table_width += cols_count + 1;
let mut remaining_width = table_width.saturating_sub(min_table_width);
if remaining_width > 0 {
let mut expandable_count = self
.columns
.iter()
.filter(|c| matches!(c.layout, Layout::Expandable(_)))
.count();
for c in self.columns.iter_mut() {
if let Layout::Expandable(max_width) = c.layout {
let new_width = compensate(c.width, max_width, remaining_width / expandable_count);
let compensation = new_width.saturating_sub(c.width);
if new_width > c.width {
c.width = new_width;
}
remaining_width -= compensation;
expandable_count -= 1;
}
}
}
}
fn generate_empty_string(&self, col_idx: usize, padding: usize) -> String {
if let Some(col) = self.columns.get(col_idx) {
let width = col.width.saturating_sub(2 * padding);
let mut result = String::with_capacity(width);
result.push_str(&" ".repeat(width));
return result;
}
String::default()
}
fn separator_chars(&self, separator: &Option<Separator>) -> (char, char, char, char) {
let ch = &self.chars;
match separator {
Some(Separator::Single) => (ch.ew, ch.news, ch.nes, ch.nws),
Some(Separator::Double) => (ch.dew, ch.dnews, ch.dnes, ch.dnws),
Some(Separator::Custom(c)) => (*c, ch.news, ch.nes, ch.nws),
None => ('-', '|', '|', '|'),
}
}
fn render_row(&self, row: &'a [T]) {
let mut padded = row
.iter()
.enumerate()
.map(|(i, s)| {
let col = self.columns.get(i).unwrap();
let pad = match col.align {
Align::Left => Justify::Left,
Align::Right => Justify::Right,
Align::Center => Justify::Center,
};
match col.overflow {
Overflow::Truncate => JustedString::truncating(s.as_ref()),
Overflow::Wrap => JustedString::wrapping(s.as_ref()),
}
.justify(
col.width.saturating_sub(2 * self.padding),
col.max_lines,
pad,
)
})
.collect::<Vec<_>>();
let ns = self.chars.ns;
let len = padded.len();
let max_lines = padded.iter().map(|s| s.len()).max().unwrap_or(0);
let str_padding = self.padding;
let edg_padding = self.padding + 1;
for _ in 0..max_lines {
print!("{:edg_padding$}", ns);
for (i, vs) in padded.iter_mut().enumerate() {
let s = vs
.pop_front()
.unwrap_or_else(|| self.generate_empty_string(i, str_padding));
print!("{s}");
if i < len - 1 {
print!("{:>str_padding$}{ns}{:>str_padding$}", "", "");
}
}
println!("{:>edg_padding$}", ns);
}
}
pub fn render<R: AsRef<[T]>>(&self, rows: Vec<R>) {
let ch = &self.chars;
let cols_count = self.columns.len();
let rows_count = rows.len();
let rsep_chars = self.separator_chars(&self.rows_separator);
let hsep_chars = self.separator_chars(&self.headers_separator);
let title_width = self
.title
.as_ref()
.map(|ts| ts.title.len() + 4)
.unwrap_or(0);
let mut acc = 1;
let mut border_top = vec![ch.ew; self.width];
let mut border_btm = vec![ch.ew; self.width];
let mut hseparator = vec![hsep_chars.0; self.width];
let mut rseparator = vec![rsep_chars.0; self.width];
border_top[0] = ch.se;
border_btm[0] = ch.ne;
border_top[self.width - 1] = ch.sw;
border_btm[self.width - 1] = ch.nw;
hseparator[0] = hsep_chars.2;
rseparator[0] = rsep_chars.2;
hseparator[self.width - 1] = hsep_chars.3;
rseparator[self.width - 1] = rsep_chars.3;
for (i, spec) in self.columns.iter().enumerate() {
if i < cols_count - 1 {
acc += spec.width + 1;
border_top[acc - 1] = ch.ews;
border_btm[acc - 1] = ch.new;
hseparator[acc - 1] = hsep_chars.1;
rseparator[acc - 1] = rsep_chars.1;
}
}
if title_width > 0 && title_width < self.width - 4 {
let spec = self.title.as_ref().unwrap();
let start = match spec.align {
TitleAlign::LeftOffset(lo) => lo + 1,
TitleAlign::RightOffset(ro) => self.width - ro - title_width - 1,
};
let end = start + title_width;
let tch = ch.title;
border_top.splice(start..end, format!("{tch} {} {tch}", spec.title).chars());
}
let top = border_top.iter().collect::<String>();
let btm = border_btm.iter().collect::<String>();
let h_sep = hseparator.iter().collect::<String>();
let r_sep = rseparator.iter().collect::<String>();
println!("{top}");
if !self.headers.is_empty() {
self.render_row(self.headers.as_slice());
if self.headers_separator.is_some() {
println!("{h_sep}");
}
}
for (i, r) in rows.iter().enumerate() {
self.render_row(r.as_ref());
if i < rows_count - 1 && self.rows_separator.is_some() {
println!("{r_sep}");
}
}
println!("{btm}");
}
}
fn compensate(width: usize, max_width: usize, compensation: usize) -> usize {
let compensated = width + compensation;
if compensated > max_width {
max_width
} else {
compensated
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn basic_constraints() {
let table = FancyTable::create(FancyTableOpts::default())
.add_column_named("ID", Layout::Fixed(8))
.add_column_named("NAME", Layout::Fixed(4))
.add_column_named("ROLE", Layout::Fixed(10))
.add_column_named("PERMISSION", Layout::Expandable(30))
.add_column_named("DESCRIPTION", Layout::Expandable(150))
.add_title("props")
.padding(0)
.build();
assert_eq!(table.columns.first().unwrap().width, 8);
assert_eq!(table.columns.get(1).unwrap().width, 4);
assert_eq!(table.columns.get(2).unwrap().width, 10);
assert_eq!(table.columns.get(3).unwrap().width, 25);
assert_eq!(
table.columns.get(4).unwrap().width,
80 - 6 - 8 - 4 - 10 - 25
);
}
#[test]
fn slim_table() {
let table = FancyTable::create(FancyTableOpts::default())
.add_column_named("ID", Layout::Slim)
.add_column_named("NAME", Layout::Slim)
.add_column_named("ROLE", Layout::Fixed(10))
.add_column_named("PERMISSION", Layout::Expandable(30))
.add_column_named("DESCRIPTION", Layout::Expandable(50))
.padding(0)
.width(0)
.build();
assert_eq!(table.columns.first().unwrap().width, 2);
assert_eq!(table.columns.get(1).unwrap().width, 4);
assert_eq!(table.columns.get(2).unwrap().width, 10);
assert_eq!(table.columns.get(3).unwrap().width, 10);
assert_eq!(table.columns.get(4).unwrap().width, 11);
}
}