modcli/output/
table.rs

1use console::measure_text_width;
2use terminal_size::{terminal_size, Width};
3
4pub enum TableMode {
5    Flex,
6    Fixed(usize),
7    Full,
8}
9
10pub enum TableStyle {
11    Ascii,
12    Rounded,
13    Heavy,
14}
15
16pub fn render_table(
17    headers: &[&str],
18    rows: &[Vec<&str>],
19    mode: TableMode,
20    style: TableStyle,
21) -> String {
22    let term_width = terminal_size()
23        .map(|(Width(w), _)| w as usize)
24        .unwrap_or(80);
25    let col_count = headers.len().max(1);
26    let padding = 1;
27    let total_padding = (col_count - 1) * padding;
28
29    let col_width = match mode {
30        TableMode::Fixed(width) => width,
31        TableMode::Full => {
32            let border_space = col_count + 1; // ┏┃┃┃┓ = 4 columns + 2 sides = 5 chars
33            let usable = term_width.saturating_sub(border_space);
34            usable / col_count
35        }
36        TableMode::Flex => {
37            let content_max = headers
38                .iter()
39                .map(|h| measure_text_width(h))
40                .chain(
41                    rows.iter()
42                        .flat_map(|r| r.iter().map(|c| measure_text_width(c))),
43                )
44                .max()
45                .unwrap_or(10);
46            content_max.min((term_width.saturating_sub(total_padding)) / col_count)
47        }
48    };
49
50    let border = match style {
51        TableStyle::Ascii => BorderSet::ascii(),
52        TableStyle::Rounded => BorderSet::rounded(),
53        TableStyle::Heavy => BorderSet::heavy(),
54    };
55
56    let mut out = String::with_capacity(128);
57
58    // Top Border
59    out.push(border.top_left);
60    for i in 0..col_count {
61        out.push_str(&border.horizontal.to_string().repeat(col_width));
62        if i < col_count - 1 {
63            out.push(border.top_cross);
64        }
65    }
66    out.push(border.top_right);
67    out.push('\n');
68
69    // Header Row
70    out.push(border.vertical);
71    for h in headers.iter() {
72        out.push_str(&pad_cell(h, col_width));
73        out.push(border.vertical);
74    }
75    out.push('\n');
76
77    // Mid Border
78    out.push(border.mid_left);
79    for i in 0..col_count {
80        out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
81        if i < col_count - 1 {
82            out.push(border.mid_cross);
83        }
84    }
85    out.push(border.mid_right);
86    out.push('\n');
87
88    // Body Rows
89    for row in rows {
90        out.push(border.vertical);
91        for cell in row {
92            out.push_str(&pad_cell(cell, col_width));
93            out.push(border.vertical);
94        }
95        out.push('\n');
96    }
97
98    // Bottom Border
99    out.push(border.bottom_left);
100    for i in 0..col_count {
101        out.push_str(&border.horizontal.to_string().repeat(col_width));
102        if i < col_count - 1 {
103            out.push(border.bottom_cross);
104        }
105    }
106    out.push(border.bottom_right);
107    out.push('\n');
108
109    out
110}
111
112/// Truncates the cell to fit `width` characters visually, appending an ellipsis if needed,
113/// then pads with spaces to fill the column exactly.
114fn pad_cell(cell: &str, width: usize) -> String {
115    let truncated = truncate_to_width(cell, width);
116    let visual = measure_text_width(&truncated);
117    let pad = width.saturating_sub(visual);
118    format!("{truncated}{}", " ".repeat(pad))
119}
120
121/// Best-effort truncate that respects visual width using `console::measure_text_width`.
122/// If the content exceeds `width`, it trims to `width-1` and appends '…'.
123fn truncate_to_width(cell: &str, width: usize) -> String {
124    if width == 0 {
125        return String::new();
126    }
127    let visual = measure_text_width(cell);
128    if visual <= width {
129        return cell.to_string();
130    }
131
132    let mut out = String::new();
133    // Reserve room for ellipsis
134    let target = width.saturating_sub(1);
135    for ch in cell.chars() {
136        let next = format!("{out}{ch}");
137        if measure_text_width(&next) > target {
138            break;
139        }
140        out.push(ch);
141    }
142    out.push('…');
143    out
144}
145
146struct BorderSet {
147    top_left: char,
148    top_right: char,
149    bottom_left: char,
150    bottom_right: char,
151    top_cross: char,
152    bottom_cross: char,
153    mid_cross: char,
154    mid_left: char,
155    mid_right: char,
156    horizontal: char,
157    inner_horizontal: char,
158    vertical: char,
159}
160
161impl BorderSet {
162    fn ascii() -> Self {
163        Self {
164            top_left: '+',
165            top_right: '+',
166            bottom_left: '+',
167            bottom_right: '+',
168            top_cross: '+',
169            bottom_cross: '+',
170            mid_cross: '+',
171            mid_left: '+',
172            mid_right: '+',
173            horizontal: '-',
174            inner_horizontal: '-',
175            vertical: '|',
176        }
177    }
178
179    fn rounded() -> Self {
180        Self {
181            top_left: '╭',
182            top_right: '╮',
183            bottom_left: '╰',
184            bottom_right: '╯',
185            top_cross: '┬',
186            bottom_cross: '┴',
187            mid_cross: '┼',
188            mid_left: '├',
189            mid_right: '┤',
190            horizontal: '─',
191            inner_horizontal: '─',
192            vertical: '│',
193        }
194    }
195
196    fn heavy() -> Self {
197        Self {
198            top_left: '┏',
199            top_right: '┓',
200            bottom_left: '┗',
201            bottom_right: '┛',
202            top_cross: '┳',
203            bottom_cross: '┻',
204            mid_cross: '╋',
205            mid_left: '┣',
206            mid_right: '┫',
207            horizontal: '━',
208            inner_horizontal: '━',
209            vertical: '┃',
210        }
211    }
212}