use crate::core::border::{BorderChars, BorderType};
use crate::core::geometry::Align;
use crate::core::render::{Renderable, Rendered};
use crate::core::style::Style;
use crate::core::text::{Line, Span, Text};
use crate::core::theme::theme;
use crate::core::width::{truncate, wrap};
use crate::output::layout::pad_line;
pub struct Column {
header: Text,
align: Align,
min_width: usize,
max_width: Option<usize>,
fixed_width: Option<usize>,
wrap: bool,
}
impl Column {
pub fn new(header: impl Into<Text>) -> Self {
Self {
header: header.into(),
align: Align::Left,
min_width: 0,
max_width: None,
fixed_width: None,
wrap: false,
}
}
#[must_use]
pub fn align(mut self, align: Align) -> Self {
self.align = align;
self
}
#[must_use]
pub fn min_width(mut self, width: usize) -> Self {
self.min_width = width;
self
}
#[must_use]
pub fn max_width(mut self, width: usize) -> Self {
self.max_width = Some(width);
self
}
#[must_use]
pub fn fixed_width(mut self, width: usize) -> Self {
self.fixed_width = Some(width);
self
}
#[must_use]
pub fn wrap(mut self) -> Self {
self.wrap = true;
self
}
}
impl From<&str> for Column {
fn from(value: &str) -> Self {
Column::new(value)
}
}
impl From<String> for Column {
fn from(value: String) -> Self {
Column::new(value)
}
}
pub struct Cell {
content: Text,
align: Option<Align>,
colspan: usize,
rowspan: usize,
}
impl Cell {
pub fn new(content: impl Into<Text>) -> Self {
Self {
content: content.into(),
align: None,
colspan: 1,
rowspan: 1,
}
}
#[must_use]
pub fn align(mut self, align: Align) -> Self {
self.align = Some(align);
self
}
#[must_use]
pub fn colspan(mut self, columns: usize) -> Self {
self.colspan = columns.max(1);
self
}
#[must_use]
pub fn rowspan(mut self, rows: usize) -> Self {
self.rowspan = rows.max(1);
self
}
}
impl From<&str> for Cell {
fn from(value: &str) -> Self {
Cell::new(value)
}
}
impl From<String> for Cell {
fn from(value: String) -> Self {
Cell::new(value)
}
}
impl From<Text> for Cell {
fn from(value: Text) -> Self {
Cell::new(value)
}
}
struct Row {
cells: Vec<Cell>,
footer: bool,
}
pub struct Table {
columns: Vec<Column>,
rows: Vec<Row>,
border: BorderType,
border_style: Style,
header: bool,
header_style: Style,
striped: bool,
stripe_style: Style,
title: Option<Text>,
title_style: Style,
pad: u16,
row_separators: bool,
}
impl Default for Table {
fn default() -> Self {
let theme = theme();
Self {
columns: Vec::new(),
rows: Vec::new(),
border: theme.border,
border_style: theme.secondary,
header: true,
header_style: theme.heading,
striped: false,
stripe_style: Style::new().dim(),
title: None,
title_style: theme.heading,
pad: 1,
row_separators: false,
}
}
}
impl Table {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn column(mut self, column: impl Into<Column>) -> Self {
self.columns.push(column.into());
self
}
#[must_use]
pub fn columns<I, C>(mut self, columns: I) -> Self
where
I: IntoIterator<Item = C>,
C: Into<Column>,
{
self.columns.extend(columns.into_iter().map(Into::into));
self
}
#[must_use]
pub fn row<I, C>(mut self, cells: I) -> Self
where
I: IntoIterator<Item = C>,
C: Into<Cell>,
{
self.rows.push(Row {
cells: cells.into_iter().map(Into::into).collect(),
footer: false,
});
self
}
#[must_use]
pub fn footer_row<I, C>(mut self, cells: I) -> Self
where
I: IntoIterator<Item = C>,
C: Into<Cell>,
{
self.rows.push(Row {
cells: cells.into_iter().map(Into::into).collect(),
footer: true,
});
self
}
#[must_use]
pub fn border(mut self, border: BorderType) -> Self {
self.border = border;
self
}
#[must_use]
pub fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
#[must_use]
pub fn header(mut self, show: bool) -> Self {
self.header = show;
self
}
#[must_use]
pub fn header_style(mut self, style: Style) -> Self {
self.header_style = style;
self
}
#[must_use]
pub fn striped(mut self, striped: bool) -> Self {
self.striped = striped;
self
}
#[must_use]
pub fn stripe_style(mut self, style: Style) -> Self {
self.stripe_style = style;
self
}
#[must_use]
pub fn title_style(mut self, style: Style) -> Self {
self.title_style = style;
self
}
#[must_use]
pub fn title(mut self, title: impl Into<Text>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn pad(mut self, pad: u16) -> Self {
self.pad = pad;
self
}
#[must_use]
pub fn row_separators(mut self, on: bool) -> Self {
self.row_separators = on;
self
}
}
impl Renderable for Table {
fn render(&self, _max_width: u16) -> Rendered {
if self.columns.is_empty() {
return Rendered::empty();
}
let plan = build_plan(&self.rows, self.columns.len());
let widths = self.column_widths(&plan);
Builder::new(self, &widths, &plan).build()
}
}
impl Table {
fn column_widths(&self, plan: &[RowPlan]) -> Vec<usize> {
let mut widths = vec![0usize; self.columns.len()];
for (index, column) in self.columns.iter().enumerate() {
if self.header {
widths[index] = column.header.width();
}
}
for row in plan {
for placed in &row.cells {
if let Some(cell) = placed.cell
&& placed.colspan == 1
{
widths[placed.start] =
widths[placed.start].max(cell.content.width());
}
}
}
for (index, column) in self.columns.iter().enumerate() {
widths[index] = clamp_width(widths[index], column);
}
widths
}
}
struct PlacedCell<'a> {
cell: Option<&'a Cell>,
start: usize,
colspan: usize,
}
struct RowPlan<'a> {
cells: Vec<PlacedCell<'a>>,
footer: bool,
}
fn build_plan(rows: &[Row], cols: usize) -> Vec<RowPlan<'_>> {
let mut occupied = vec![0usize; cols];
let mut plan = Vec::with_capacity(rows.len());
for row in rows {
let mut placed = Vec::new();
let mut cells = row.cells.iter();
let mut col = 0;
while col < cols {
if occupied[col] > 0 {
occupied[col] -= 1;
placed.push(PlacedCell {
cell: None,
start: col,
colspan: 1,
});
col += 1;
continue;
}
match cells.next() {
Some(cell) => {
let span = cell.colspan.min(cols - col).max(1);
if cell.rowspan > 1 {
for slot in occupied.iter_mut().skip(col).take(span) {
*slot = cell.rowspan - 1;
}
}
placed.push(PlacedCell {
cell: Some(cell),
start: col,
colspan: span,
});
col += span;
}
None => {
placed.push(PlacedCell {
cell: None,
start: col,
colspan: 1,
});
col += 1;
}
}
}
plan.push(RowPlan {
cells: placed,
footer: row.footer,
});
}
plan
}
fn clamp_width(natural: usize, column: &Column) -> usize {
if let Some(fixed) = column.fixed_width {
return fixed;
}
let mut width = natural.max(column.min_width);
if let Some(max) = column.max_width {
width = width.min(max);
}
width.max(1)
}
struct Builder<'a> {
table: &'a Table,
widths: &'a [usize],
plan: &'a [RowPlan<'a>],
chars: BorderChars,
}
impl<'a> Builder<'a> {
fn new(
table: &'a Table,
widths: &'a [usize],
plan: &'a [RowPlan<'a>],
) -> Self {
Self {
table,
widths,
plan,
chars: table.border.chars(),
}
}
fn build(&self) -> Rendered {
let mut lines = Vec::new();
self.push_title(&mut lines);
self.push_border(&mut lines, Edge::Top);
if self.table.header {
self.push_header(&mut lines);
self.push_border(&mut lines, Edge::Middle);
}
self.push_body(&mut lines);
self.push_border(&mut lines, Edge::Bottom);
Rendered::new(lines)
}
fn push_title(&self, lines: &mut Vec<Line>) {
let Some(title) = &self.table.title else {
return;
};
let title_line = title.lines.first().cloned().unwrap_or_default();
let width = self.total_width();
lines.push(pad_line(
title_line,
width,
Align::Center,
self.table.title_style,
));
}
fn push_header(&self, lines: &mut Vec<Line>) {
let cells: Vec<Cell> = self
.table
.columns
.iter()
.map(|column| Cell::new(column.header.clone()).align(column.align))
.collect();
let placed: Vec<PlacedCell> = cells
.iter()
.enumerate()
.map(|(index, cell)| PlacedCell {
cell: Some(cell),
start: index,
colspan: 1,
})
.collect();
self.push_row(lines, &placed, self.table.header_style, false);
}
fn push_body(&self, lines: &mut Vec<Line>) {
let last_body = self.plan.iter().rposition(|r| !r.footer);
let mut body_index = 0;
let mut footer_started = false;
for (index, row) in self.plan.iter().enumerate() {
if row.footer && !footer_started {
self.push_border(lines, Edge::Middle);
footer_started = true;
}
let striped = self.table.striped && body_index % 2 == 1;
let style = if striped {
self.table.stripe_style
} else {
Style::new()
};
self.push_row(lines, &row.cells, style, striped);
if !row.footer {
body_index += 1;
if self.table.row_separators && Some(index) != last_body {
self.push_border(lines, Edge::Middle);
}
}
}
}
fn push_row(
&self,
lines: &mut Vec<Line>,
placed: &[PlacedCell],
style: Style,
fill_bg: bool,
) {
let cell_lines = self.cell_lines(placed, style);
let height = cell_lines.iter().map(Vec::len).max().unwrap_or(1).max(1);
for physical in 0..height {
lines.push(self.row_line(placed, &cell_lines, physical, fill_bg));
}
}
fn cell_lines(
&self,
placed: &[PlacedCell],
style: Style,
) -> Vec<Vec<Line>> {
placed
.iter()
.map(|slot| match slot.cell {
None => vec![Line::default()],
Some(cell) => {
let width = self.span_width(slot.start, slot.colspan);
let wrap = self.column_wraps(slot.start);
format_cell(cell, width, style, wrap)
}
})
.collect()
}
fn column_wraps(&self, index: usize) -> bool {
self.table.columns.get(index).is_some_and(|c| c.wrap)
}
fn row_line(
&self,
placed: &[PlacedCell],
cell_lines: &[Vec<Line>],
physical: usize,
fill_bg: bool,
) -> Line {
let pad = self.table.pad as usize;
let mut spans = vec![self.vbar()];
for (slot_index, slot) in placed.iter().enumerate() {
let width = self.span_width(slot.start, slot.colspan);
let align = slot
.cell
.and_then(|c| c.align)
.unwrap_or(self.align_for(slot.start));
let content = blank_or(cell_lines, slot_index, physical);
let fill = if fill_bg {
self.table.stripe_style
} else {
Style::new()
};
push_cell(&mut spans, content, width, pad, align, fill);
spans.push(self.vbar());
}
Line::new(spans)
}
fn align_for(&self, index: usize) -> Align {
self.table
.columns
.get(index)
.map_or(Align::Left, |column| column.align)
}
fn span_width(&self, start: usize, colspan: usize) -> usize {
let pad = self.table.pad as usize;
let end = (start + colspan).min(self.widths.len());
let content: usize = self.widths[start..end].iter().sum();
let extra = colspan.saturating_sub(1) * (2 * pad + 1);
content + extra
}
fn total_width(&self) -> usize {
let pad = self.table.pad as usize;
let content: usize = self.widths.iter().sum();
let cells = self.widths.len();
content + cells * 2 * pad + cells + 1
}
fn vbar(&self) -> Span {
Span::styled(self.chars.vertical.to_string(), self.table.border_style)
}
fn push_border(&self, lines: &mut Vec<Line>, edge: Edge) {
if self.table.border.is_none() {
return;
}
let (left, mid, right) = edge.corners(&self.chars);
let pad = self.table.pad as usize;
let mut content = String::new();
content.push(left);
for (index, width) in self.widths.iter().enumerate() {
let segment = width + 2 * pad;
content
.push_str(&self.chars.horizontal.to_string().repeat(segment));
if index + 1 < self.widths.len() {
content.push(mid);
}
}
content.push(right);
lines.push(Line::styled(content, self.table.border_style));
}
}
#[derive(Clone, Copy)]
enum Edge {
Top,
Middle,
Bottom,
}
impl Edge {
fn corners(self, chars: &BorderChars) -> (char, char, char) {
match self {
Edge::Top => (chars.top_left, chars.tee_down, chars.top_right),
Edge::Middle => (chars.tee_right, chars.cross, chars.tee_left),
Edge::Bottom => {
(chars.bottom_left, chars.tee_up, chars.bottom_right)
}
}
}
}
fn format_cell(
cell: &Cell,
width: usize,
style: Style,
wrap_cell: bool,
) -> Vec<Line> {
let mut out = Vec::new();
for line in &cell.content.lines {
let plain = line.plain();
if line.width() <= width {
out.push(restyle(line.clone(), style));
} else if wrap_cell {
for chunk in wrap(&plain, width) {
out.push(Line::styled(chunk, style));
}
} else {
let span_style = line.spans.first().map_or(style, |s| s.style);
let cell_style = style.patch(span_style);
out.push(Line::styled(truncate(&plain, width, "…"), cell_style));
}
}
if out.is_empty() {
out.push(Line::default());
}
out
}
fn restyle(line: Line, base: Style) -> Line {
let spans = line
.spans
.into_iter()
.map(|mut span| {
span.style = base.patch(span.style);
span
})
.collect();
Line::new(spans)
}
fn blank_or(cell_lines: &[Vec<Line>], index: usize, physical: usize) -> Line {
cell_lines
.get(index)
.and_then(|lines| lines.get(physical))
.cloned()
.unwrap_or_default()
}
fn push_cell(
spans: &mut Vec<Span>,
content: Line,
width: usize,
pad: usize,
align: Align,
fill: Style,
) {
let truncated = clip(content, width);
let padded = pad_line(truncated, width, align, fill);
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), fill));
}
spans.extend(padded.spans);
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), fill));
}
}
fn clip(line: Line, width: usize) -> Line {
if line.width() <= width {
return line;
}
let style = line.spans.first().map(|s| s.style).unwrap_or_default();
Line::styled(truncate(&line.plain(), width, "…"), style)
}
#[cfg(test)]
mod tests {
use super::*;
fn plain(rendered: &Rendered) -> Vec<String> {
rendered.lines.iter().map(Line::plain).collect()
}
#[test]
fn renders_header_and_rows_with_borders() {
let table = Table::new()
.columns(["A", "B"])
.row(["1", "2"])
.border(BorderType::Single);
let lines = plain(&table.render(80));
assert!(lines[0].starts_with('┌'));
assert!(lines[1].contains('A') && lines[1].contains('B'));
assert!(lines[2].starts_with('├'));
assert!(lines[3].contains('1') && lines[3].contains('2'));
assert!(lines[4].starts_with('└'));
}
#[test]
fn aligns_and_pads_cells() {
let table = Table::new()
.header(false)
.column(Column::new("").align(Align::Right))
.row(["7"])
.border(BorderType::Ascii);
let lines = plain(&table.render(80));
assert!(lines.iter().any(|l| l.contains('7')));
}
#[test]
fn truncates_overlong_cells_to_max_width() {
let table = Table::new()
.header(false)
.column(Column::new("").max_width(4))
.row(["abcdefgh"])
.border(BorderType::Single);
let lines = plain(&table.render(80));
assert!(lines.iter().any(|l| l.contains('…')));
}
#[test]
fn rowspan_spans_following_rows() {
let table = Table::new()
.header(false)
.columns(["A", "B"])
.row([Cell::new("x").rowspan(2), Cell::new("1")])
.row(["2"])
.border(BorderType::Single);
let lines = plain(&table.render(80));
assert!(lines.iter().any(|l| l.contains('x') && l.contains('1')));
let two = lines.iter().find(|l| l.contains('2')).unwrap();
assert!(!two.contains('x'));
}
#[test]
fn colspan_widens_a_cell() {
let table = Table::new()
.columns(["A", "B"])
.row([Cell::new("wide").colspan(2)])
.border(BorderType::Single);
let lines = plain(&table.render(80));
assert!(lines.iter().any(|l| l.contains("wide")));
}
}