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