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