Skip to main content

vtcode_core/ui/
table_formatter.rs

1//! Professional table formatting for terminal output with automatic column sizing.
2//!
3//! This module provides utilities to render markdown tables with proper alignment,
4//! column width calculation, and capability-aware box-drawing characters.
5
6use unicode_width::UnicodeWidthStr;
7
8/// Horizontal alignment for table cells
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum Alignment {
11    /// Left-aligned content with right padding
12    Left,
13    /// Center-aligned content with equal padding on both sides
14    Center,
15    /// Right-aligned content with left padding
16    Right,
17}
18
19/// Information about a single table column
20#[derive(Clone, Debug)]
21pub struct TableColumn {
22    /// The minimum width needed for this column
23    pub width: usize,
24    /// Cell alignment direction
25    pub alignment: Alignment,
26    /// Column header text
27    pub header: String,
28}
29
30impl TableColumn {
31    /// Create a new column with header and calculate width
32    pub fn new(header: impl Into<String>, alignment: Alignment) -> Self {
33        let header_str = header.into();
34        let width = UnicodeWidthStr::width(header_str.as_str());
35        Self {
36            width,
37            alignment,
38            header: header_str,
39        }
40    }
41
42    /// Update column width based on cell content, keeping maximum
43    pub fn measure_cell(&mut self, content: &str) {
44        let cell_width = UnicodeWidthStr::width(content);
45        self.width = self.width.max(cell_width);
46    }
47}
48
49/// Table formatter with column width detection and alignment
50#[derive(Clone, Debug)]
51pub struct TableFormatter {
52    /// Column definitions with calculated widths
53    pub columns: Vec<TableColumn>,
54    /// Whether to use Unicode box-drawing characters (vs ASCII)
55    pub use_unicode: bool,
56}
57
58impl TableFormatter {
59    /// Create a new table formatter with specified columns
60    pub fn new(columns: Vec<TableColumn>, use_unicode: bool) -> Self {
61        Self {
62            columns,
63            use_unicode,
64        }
65    }
66
67    /// Measure all content and update column widths
68    pub fn measure_content(&mut self, rows: &[Vec<String>]) {
69        for row in rows {
70            for (col_idx, cell) in row.iter().enumerate() {
71                if let Some(column) = self.columns.get_mut(col_idx) {
72                    column.measure_cell(cell);
73                }
74            }
75        }
76    }
77
78    /// Render a header separator line
79    pub fn render_separator(&self) -> String {
80        let (left, junction, right, line) = if self.use_unicode {
81            ('├', '┼', '┤', '─')
82        } else {
83            ('+', '+', '+', '-')
84        };
85
86        let mut separator = String::from(left);
87        for (idx, column) in self.columns.iter().enumerate() {
88            separator.push_str(&line.to_string().repeat(column.width + 2));
89            if idx < self.columns.len() - 1 {
90                separator.push(junction);
91            }
92        }
93        separator.push(right);
94        separator
95    }
96
97    /// Format a single cell with alignment and padding
98    fn format_cell(&self, content: &str, alignment: Alignment, width: usize) -> String {
99        let content_width = UnicodeWidthStr::width(content);
100        if content_width >= width {
101            return content.to_string();
102        }
103
104        let padding = width - content_width;
105        match alignment {
106            Alignment::Left => {
107                format!("{}{}", content, " ".repeat(padding))
108            }
109            Alignment::Center => {
110                let left_pad = padding / 2;
111                let right_pad = padding - left_pad;
112                format!(
113                    "{}{}{}",
114                    " ".repeat(left_pad),
115                    content,
116                    " ".repeat(right_pad)
117                )
118            }
119            Alignment::Right => {
120                format!("{}{}", " ".repeat(padding), content)
121            }
122        }
123    }
124
125    /// Render a single row of cells
126    pub fn render_row(&self, cells: &[String]) -> String {
127        let (sep, left, right) = if self.use_unicode {
128            ("│", "│", "│")
129        } else {
130            ("|", "|", "|")
131        };
132
133        let mut row = String::from(left);
134        for (idx, (cell, column)) in cells.iter().zip(self.columns.iter()).enumerate() {
135            let formatted = self.format_cell(cell, column.alignment, column.width);
136            row.push(' ');
137            row.push_str(&formatted);
138            row.push(' ');
139            if idx < self.columns.len() - 1 {
140                row.push_str(sep);
141            }
142        }
143        row.push_str(right);
144        row
145    }
146
147    /// Render header row with separator line
148    pub fn render_header(&self) -> Vec<String> {
149        let headers: Vec<String> = self.columns.iter().map(|col| col.header.clone()).collect();
150
151        vec![self.render_row(&headers), self.render_separator()]
152    }
153
154    /// Render entire table with header, rows, and footer
155    pub fn render_table(&self, rows: &[Vec<String>]) -> Vec<String> {
156        let mut output = Vec::new();
157
158        // Top border
159        let (left, right, line) = if self.use_unicode {
160            ('┌', '┐', '─')
161        } else {
162            ('+', '+', '-')
163        };
164        let mut top_border = String::from(left);
165        for (idx, column) in self.columns.iter().enumerate() {
166            top_border.push_str(&line.to_string().repeat(column.width + 2));
167            if idx < self.columns.len() - 1 {
168                let junction = if self.use_unicode { '┬' } else { '+' };
169                top_border.push(junction);
170            }
171        }
172        top_border.push(right);
173        output.push(top_border);
174
175        // Header with separator
176        output.extend(self.render_header());
177
178        // Data rows
179        for row in rows {
180            output.push(self.render_row(row));
181        }
182
183        // Bottom border
184        let (left, right) = if self.use_unicode {
185            ('└', '┘')
186        } else {
187            ('+', '+')
188        };
189        let mut bottom_border = String::from(left);
190        for (idx, column) in self.columns.iter().enumerate() {
191            bottom_border.push_str(&line.to_string().repeat(column.width + 2));
192            if idx < self.columns.len() - 1 {
193                let junction = if self.use_unicode { '┴' } else { '+' };
194                bottom_border.push(junction);
195            }
196        }
197        bottom_border.push(right);
198        output.push(bottom_border);
199
200        output
201    }
202
203    /// Get total table width including borders and padding
204    pub fn total_width(&self) -> usize {
205        // Left border (1) + cells with padding (each 2) + separators (n-1) + right border (1)
206        let content_width: usize = self.columns.iter().map(|c| c.width + 2).sum();
207        let separators = if self.columns.is_empty() {
208            0
209        } else {
210            self.columns.len() - 1
211        };
212        1 + content_width + separators + 1
213    }
214
215    /// Wrap table to fit within maximum width if needed
216    pub fn wrap_to_width(&mut self, max_width: usize) {
217        if self.total_width() <= max_width || self.columns.is_empty() {
218            return;
219        }
220
221        // Proportionally reduce column widths
222        let total_content: usize = self.columns.iter().map(|c| c.width).sum();
223        let available = max_width.saturating_sub(self.columns.len() + 3); // Borders + padding
224
225        let scale = (available as f64) / (total_content as f64);
226        for column in &mut self.columns {
227            column.width = ((column.width as f64) * scale).max(3.0) as usize;
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_column_width_calculation() {
238        let mut col = TableColumn::new("Name", Alignment::Left);
239        assert_eq!(col.width, 4);
240        col.measure_cell("Alexander");
241        assert_eq!(col.width, 9);
242    }
243
244    #[test]
245    fn test_format_cell_left_align() {
246        let formatter = TableFormatter::new(vec![], false);
247        let result = formatter.format_cell("Hi", Alignment::Left, 5);
248        assert_eq!(result, "Hi   ");
249    }
250
251    #[test]
252    fn test_format_cell_center_align() {
253        let formatter = TableFormatter::new(vec![], false);
254        let result = formatter.format_cell("Hi", Alignment::Center, 5);
255        assert_eq!(result, " Hi  ");
256    }
257
258    #[test]
259    fn test_format_cell_right_align() {
260        let formatter = TableFormatter::new(vec![], false);
261        let result = formatter.format_cell("Hi", Alignment::Right, 5);
262        assert_eq!(result, "   Hi");
263    }
264
265    #[test]
266    fn test_separator_rendering() {
267        let formatter = TableFormatter::new(
268            vec![
269                TableColumn::new("A", Alignment::Left),
270                TableColumn::new("B", Alignment::Left),
271            ],
272            false,
273        );
274        let sep = formatter.render_separator();
275        assert!(sep.starts_with('+'));
276        assert!(sep.ends_with('+'));
277        assert!(sep.contains("+"));
278    }
279
280    #[test]
281    fn test_total_width() {
282        let formatter = TableFormatter::new(
283            vec![
284                TableColumn::new("Col1", Alignment::Left),
285                TableColumn::new("Col2", Alignment::Left),
286            ],
287            false,
288        );
289        // 1 (left border) + (4+2) + 1 (separator) + (4+2) + 1 (right border) = 15
290        assert_eq!(formatter.total_width(), 15);
291    }
292
293    #[test]
294    fn test_unicode_vs_ascii() {
295        let unicode_fmt = TableFormatter::new(vec![TableColumn::new("A", Alignment::Left)], true);
296        let ascii_fmt = TableFormatter::new(vec![TableColumn::new("A", Alignment::Left)], false);
297
298        let unicode_sep = unicode_fmt.render_separator();
299        let ascii_sep = ascii_fmt.render_separator();
300
301        assert!(unicode_sep.contains("├"));
302        assert!(ascii_sep.contains("+"));
303    }
304}