Skip to main content

sparrow/tui/formatters/
table.rs

1// ─── Table formatting ─────────────────────────────────────────────────────────
2// Auto-sizes columns based on content, aligns text, adds bold header row with
3// separator, and handles terminal width overflow by truncating cells.
4
5/// ANSI style constants for table rendering.
6pub struct TableStyles {
7    pub header: &'static str,
8    pub cell: &'static str,
9    pub separator: &'static str,
10    pub border: &'static str,
11    pub reset: &'static str,
12}
13
14impl Default for TableStyles {
15    fn default() -> Self {
16        Self {
17            header: "\x1b[1;38;2;242;169;60m",  // bold amber
18            cell: "\x1b[38;2;236;226;207m",      // normal fg
19            separator: "\x1b[38;2;92;83;70m",    // dimmer
20            border: "\x1b[38;2;92;83;70m",       // dimmer
21            reset: "\x1b[0m",
22        }
23    }
24}
25
26/// Alignment for table columns.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum Align {
29    Left,
30    Center,
31    Right,
32}
33
34/// Configuration for a table column.
35#[derive(Debug, Clone)]
36pub struct ColumnConfig {
37    pub header: String,
38    pub align: Align,
39    pub min_width: usize,
40    pub max_width: Option<usize>,
41}
42
43impl ColumnConfig {
44    pub fn new(header: &str) -> Self {
45        Self {
46            header: header.to_string(),
47            align: Align::Left,
48            min_width: header.len(),
49            max_width: None,
50        }
51    }
52
53    pub fn with_align(mut self, align: Align) -> Self {
54        self.align = align;
55        self
56    }
57}
58
59/// Render a table as ANSI-formatted terminal text.
60///
61/// `headers` — column headers (displayed in bold)
62/// `rows` — data rows, each being a Vec of cell strings
63/// `max_width` — optional terminal width to constrain total table width
64/// `styles` — optional custom styles
65///
66/// Auto-sizes columns based on content. Truncates cells that exceed their
67/// computed column width. Adds separator lines between header and body.
68pub fn render_table(
69    headers: &[&str],
70    rows: &[Vec<String>],
71    max_width: Option<usize>,
72    styles: Option<&TableStyles>,
73) -> String {
74    if headers.is_empty() {
75        return String::new();
76    }
77
78    let styles = styles.unwrap_or(&TABLE_STYLES_DEFAULT);
79    let col_count = headers.len();
80
81    // Compute initial column widths from headers
82    let mut col_widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
83
84    // Expand based on cell content
85    for row in rows {
86        for (i, cell) in row.iter().enumerate() {
87            if i >= col_count {
88                break;
89            }
90            let cell_len = cell.chars().count();
91            if cell_len > col_widths[i] {
92                col_widths[i] = cell_len;
93            }
94        }
95    }
96
97    // Apply minimum width of 3
98    for w in col_widths.iter_mut() {
99        *w = (*w).max(3);
100    }
101
102    // If max_width is specified, scale down columns proportionally
103    if let Some(max_w) = max_width {
104        let separator_width = (col_count + 1) * 3; // " │ " separators
105        let total_content: usize = col_widths.iter().sum::<usize>() + separator_width;
106        if total_content > max_w {
107            // Scale down proportionally
108            let available = max_w.saturating_sub(separator_width);
109            let scale = available as f64 / (total_content - separator_width) as f64;
110            for w in col_widths.iter_mut() {
111                *w = ((*w as f64) * scale).max(3.0) as usize;
112            }
113        }
114    }
115
116    // Build the table
117    let mut out = String::new();
118
119    // Top border
120    render_horizontal_line(&mut out, &col_widths, &styles);
121
122    // Header row
123    let header_cells: Vec<String> = headers.iter().map(|h| h.to_string()).collect();
124    render_row(&mut out, &header_cells, &col_widths, &styles, true);
125
126    // Header/body separator
127    render_separator(&mut out, &col_widths, &styles);
128
129    // Data rows
130    for row in rows {
131        let cells: Vec<String> = row.iter().map(|c| c.to_string()).collect();
132        render_row(&mut out, &cells, &col_widths, &styles, false);
133    }
134
135    // Bottom border
136    render_horizontal_line(&mut out, &col_widths, &styles);
137
138    out
139}
140
141/// Render a table using column configs for fine-grained control.
142pub fn render_table_with_config(
143    columns: &[ColumnConfig],
144    rows: &[Vec<String>],
145    max_width: Option<usize>,
146    styles: Option<&TableStyles>,
147) -> String {
148    let headers: Vec<&str> = columns.iter().map(|c| c.header.as_str()).collect();
149    render_table(&headers, rows, max_width, styles)
150}
151
152// ─── Internal helpers ─────────────────────────────────────────────────────────
153
154static TABLE_STYLES_DEFAULT: TableStyles = TableStyles {
155    header: "\x1b[1;38;2;242;169;60m",
156    cell: "\x1b[38;2;236;226;207m",
157    separator: "\x1b[38;2;92;83;70m",
158    border: "\x1b[38;2;92;83;70m",
159    reset: "\x1b[0m",
160};
161
162fn render_horizontal_line(out: &mut String, widths: &[usize], s: &TableStyles) {
163    out.push_str(s.border);
164    for &w in widths {
165        out.push_str("├");
166        out.push_str(&"─".repeat(w + 2));
167    }
168    out.push_str("┤");
169    out.push_str(s.reset);
170    out.push('\n');
171}
172
173fn render_separator(out: &mut String, widths: &[usize], s: &TableStyles) {
174    out.push_str(s.border);
175    for &w in widths {
176        out.push_str("├");
177        out.push_str(&"─".repeat(w + 2));
178    }
179    out.push_str("┤");
180    out.push_str(s.reset);
181    out.push('\n');
182}
183
184fn render_row(
185    out: &mut String,
186    cells: &[String],
187    widths: &[usize],
188    s: &TableStyles,
189    is_header: bool,
190) {
191    let style = if is_header { s.header } else { s.cell };
192
193    out.push_str(s.border);
194    out.push_str("│");
195
196    for (i, cell) in cells.iter().enumerate() {
197        if i >= widths.len() {
198            break;
199        }
200        let w = widths[i];
201        let display = truncate_cell(cell, w);
202        out.push(' ');
203        out.push_str(style);
204        out.push_str(&display);
205        // Pad with spaces
206        let display_len = display.chars().count();
207        if display_len < w {
208            out.push_str(&" ".repeat(w - display_len));
209        }
210        out.push_str(s.reset);
211        out.push(' ');
212        out.push_str(s.border);
213        out.push_str("│");
214    }
215
216    // If we have fewer cells than columns, add empty cells
217    for i in cells.len()..widths.len() {
218        let w = widths[i];
219        out.push(' ');
220        out.push_str(&" ".repeat(w));
221        out.push(' ');
222        out.push_str(s.border);
223        out.push_str("│");
224    }
225
226    out.push_str(s.reset);
227    out.push('\n');
228}
229
230/// Truncate a cell to fit within the given width, adding an ellipsis if needed.
231fn truncate_cell(text: &str, width: usize) -> String {
232    if width == 0 {
233        return String::new();
234    }
235    if text.chars().count() <= width {
236        return text.to_string();
237    }
238    if width <= 1 {
239        return "…".to_string();
240    }
241    let mut out: String = text.chars().take(width - 1).collect();
242    out.push('…');
243    out
244}