Skip to main content

ic_host_text/table/
mod.rs

1//! Unicode display-width alignment over escaped caller-selected cells.
2use crate::{escape::terminal_text, width::display_width};
3use std::borrow::Cow;
4
5/// Caller-selected alignment of one complete column.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum Alignment {
8    /// Pad after the cell.
9    Left,
10    /// Pad before the cell.
11    Right,
12}
13
14/// Explicit formatting choices; no ambient terminal policy or defaults.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub struct Options {
17    /// Spaces between columns.
18    pub column_gap: usize,
19    /// Whether a dashed line follows the headers.
20    pub header_separator: bool,
21}
22
23/// Render headers and rows after escaping every cell, including headers.
24///
25/// Widths sum Unicode scalar columns, like [`crate::width::fit_line`] and
26/// [`crate::progress::ProgressLine`]. Joined emoji count their scalar widths
27/// separately; sequence-specific ligatures do not alter column accounting.
28/// Rows have the same column count by type. Header/data lines omit trailing
29/// whitespace; the result has no final newline. Zero columns return empty text.
30/// Caller owns input/output size, row construction, headers, column choices,
31/// truncation and all output, terminal detection, colour and progress behavior.
32/// Storage allocation follows ordinary String/Vec allocation semantics.
33#[must_use]
34pub fn render<const N: usize>(
35    headers: &[impl AsRef<str>; N],
36    rows: &[[impl AsRef<str>; N]],
37    alignments: &[Alignment; N],
38    options: Options,
39) -> String {
40    if N == 0 {
41        return String::new();
42    }
43    let headers = headers.each_ref().map(|cell| terminal_text(cell.as_ref()));
44    let rows = rows
45        .iter()
46        .map(|row| row.each_ref().map(|cell| terminal_text(cell.as_ref())))
47        .collect::<Vec<_>>();
48    let mut widths = [0; N];
49    measure_row(&mut widths, &headers);
50    for row in &rows {
51        measure_row(&mut widths, row);
52    }
53    let gap = " ".repeat(options.column_gap);
54    let mut output = String::new();
55    render_row(&headers, &widths, alignments, &gap, &mut output);
56    if options.header_separator {
57        output.push('\n');
58        for (index, width) in widths.iter().enumerate() {
59            if index != 0 {
60                output.push_str(&gap);
61            }
62            output.extend(std::iter::repeat_n('-', *width));
63        }
64    }
65    for row in &rows {
66        output.push('\n');
67        render_row(row, &widths, alignments, &gap, &mut output);
68    }
69    output
70}
71
72/// Render a dynamic-column table with ASCII borders and left-aligned cells.
73///
74/// All cells are terminal-escaped before measuring the same Unicode scalar
75/// columns as [`render`]. Missing cells are empty; extra cells create unnamed
76/// columns. No columns returns empty text. Header-only output retains its
77/// separator. The result has no final newline and preserves cell whitespace.
78/// Callers own value semantics, row construction, input/output budgets and all
79/// terminal/output policy. Allocation follows ordinary String/Vec semantics.
80#[must_use]
81pub fn render_bordered(headers: &[impl AsRef<str>], rows: &[Vec<impl AsRef<str>>]) -> String {
82    let headers = headers
83        .iter()
84        .map(|cell| terminal_text(cell.as_ref()))
85        .collect::<Vec<_>>();
86    let rows = rows
87        .iter()
88        .map(|row| {
89            row.iter()
90                .map(|cell| terminal_text(cell.as_ref()))
91                .collect::<Vec<_>>()
92        })
93        .collect::<Vec<_>>();
94    let mut widths = vec![0; headers.len()];
95    measure_row(&mut widths, &headers);
96    for row in &rows {
97        widths.resize(widths.len().max(row.len()), 0);
98        measure_row(&mut widths, row);
99    }
100    if widths.is_empty() {
101        return String::new();
102    }
103    let mut separator = String::from("+");
104    for width in &widths {
105        separator.extend(std::iter::repeat_n('-', width.saturating_add(2)));
106        separator.push('+');
107    }
108    let mut output = separator.clone();
109    // Header and data rows share one border/padding path. The separator belongs
110    // after the header even when there are no data rows.
111    for (index, row) in std::iter::once(&headers).chain(&rows).enumerate() {
112        output.push_str("\n| ");
113        append_cells(
114            row,
115            &widths,
116            std::iter::repeat(Alignment::Left),
117            " | ",
118            &mut output,
119        );
120        output.push_str(" |");
121        if index == 0 {
122            output.push('\n');
123            output.push_str(&separator);
124        }
125    }
126    if !rows.is_empty() {
127        output.push('\n');
128        output.push_str(&separator);
129    }
130    output
131}
132
133fn measure_row(widths: &mut [usize], row: &[Cow<'_, str>]) {
134    for (width, value) in widths.iter_mut().zip(row) {
135        *width = (*width).max(display_width(value));
136    }
137}
138
139fn render_row(
140    row: &[Cow<'_, str>],
141    widths: &[usize],
142    alignments: &[Alignment],
143    gap: &str,
144    output: &mut String,
145) {
146    let start = output.len();
147    append_cells(row, widths, alignments.iter().copied(), gap, output);
148    // Trim only this row so empty cells cannot erase earlier line separators.
149    output.truncate(start + output[start..].trim_end().len());
150}
151
152fn append_cells(
153    row: &[Cow<'_, str>],
154    widths: &[usize],
155    alignments: impl Iterator<Item = Alignment>,
156    gap: &str,
157    output: &mut String,
158) {
159    for (index, (width, alignment)) in widths.iter().zip(alignments).enumerate() {
160        if index != 0 {
161            output.push_str(gap);
162        }
163        let value = row.get(index).map_or("", |cell| cell.as_ref());
164        let padding = width.saturating_sub(display_width(value));
165        if alignment == Alignment::Right {
166            output.extend(std::iter::repeat_n(' ', padding));
167        }
168        output.push_str(value);
169        if alignment == Alignment::Left {
170            output.extend(std::iter::repeat_n(' ', padding));
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    const OPTIONS: Options = Options {
179        column_gap: 2,
180        header_separator: true,
181    };
182
183    #[test]
184    fn joined_sequences_share_clipping_and_progress_columns() {
185        let emoji = "👩‍💻";
186        let clipped = crate::width::fit_line(emoji, 4);
187        assert_eq!(clipped, emoji);
188        let mut progress = crate::progress::ProgressLine::new();
189        assert_eq!(progress.update(&clipped), "\r👩‍💻");
190        assert_eq!(progress.finish("x"), "\rx   \n");
191        assert_eq!(
192            render(
193                &["X", "N"],
194                &[[clipped.as_str(), "1"], ["abcd", "2"]],
195                &[Alignment::Left, Alignment::Right],
196                OPTIONS,
197            ),
198            "X     N\n----  -\n👩‍💻  1\nabcd  2"
199        );
200    }
201
202    #[test]
203    fn wide_and_combining_cells_align_in_display_columns() {
204        assert_eq!(
205            render(
206                &["NAME", "N"],
207                &[["界", "7"], ["e\u{301}", "123"]],
208                &[Alignment::Left, Alignment::Right],
209                OPTIONS
210            ),
211            "NAME    N\n----  ---\n界      7\ne\u{301}     123"
212        );
213    }
214
215    #[test]
216    fn escaped_headers_and_cells_are_measured_and_stay_on_their_rows() {
217        let text = render(
218            &["H\n"],
219            &[["x\x1b[2J\t\u{2029}"]],
220            &[Alignment::Left],
221            OPTIONS,
222        );
223        assert_eq!(text.lines().count(), 3);
224        assert!(text.starts_with("H\\n\n"));
225        assert!(text.ends_with("x\\u{1b}[2J\\t\\u{2029}"));
226        assert!(!text.contains('\x1b'));
227    }
228
229    #[test]
230    fn whitespace_cells_preserve_empty_rows_and_separator_spacing() {
231        assert_eq!(
232            render(
233                &["  ", "B "],
234                &[["", ""], ["x ", "\u{2003}"], ["界", "7"]],
235                &[Alignment::Left, Alignment::Right],
236                OPTIONS,
237            ),
238            "    B\n--  --\n\nx\n界   7"
239        );
240        assert_eq!(
241            render(&["H"], &[[""], [""]], &[Alignment::Left], OPTIONS),
242            "H\n-\n\n"
243        );
244        assert_eq!(
245            render(&["", ""], &[["", ""]], &[Alignment::Left; 2], OPTIONS),
246            "\n  \n"
247        );
248    }
249
250    #[test]
251    fn explicit_options_and_empty_tables_have_no_hidden_defaults() {
252        assert_eq!(
253            render(
254                &["A", "B"],
255                &[["1", "2"]],
256                &[Alignment::Left; 2],
257                Options {
258                    column_gap: 0,
259                    header_separator: false
260                }
261            ),
262            "AB\n12"
263        );
264        assert_eq!(
265            render(&["A"], &[] as &[[&str; 1]], &[Alignment::Left], OPTIONS),
266            "A\n-"
267        );
268        assert_eq!(
269            render(&[] as &[&str; 0], &[] as &[[&str; 0]], &[], OPTIONS),
270            ""
271        );
272    }
273
274    #[test]
275    fn bordered_dynamic_columns_preserve_layout_and_ragged_cells() {
276        // Match CLI-owned String headers and dynamic String rows.
277        assert_eq!(
278            render_bordered(&[String::from("A")], &[vec![String::from("x")]]),
279            "+---+\n| A |\n+---+\n| x |\n+---+"
280        );
281        assert_eq!(
282            render_bordered(&["A", "B"], &[vec!["x", "123"], vec!["long"]]),
283            "+------+-----+\n| A    | B   |\n+------+-----+\n| x    | 123 |\n| long |     |\n+------+-----+"
284        );
285        assert_eq!(
286            render_bordered(&["A"], &[vec!["x", "y"]]),
287            "+---+---+\n| A |   |\n+---+---+\n| x | y |\n+---+---+"
288        );
289        assert_eq!(
290            render_bordered(&["A"], &[] as &[Vec<&str>]),
291            "+---+\n| A |\n+---+"
292        );
293        assert_eq!(render_bordered(&[] as &[&str], &[] as &[Vec<&str>]), "");
294        assert_eq!(render_bordered(&[] as &[&str], &[vec![] as Vec<&str>]), "");
295        assert_eq!(
296            render_bordered(&[] as &[&str], &[vec!["x"]]),
297            "+---+\n|   |\n+---+\n| x |\n+---+"
298        );
299        assert_eq!(
300            render_bordered(&["A", "B"], &[vec![] as Vec<&str>, vec!["  ", "x "]]),
301            "+----+----+\n| A  | B  |\n+----+----+\n|    |    |\n|    | x  |\n+----+----+"
302        );
303    }
304
305    #[test]
306    fn bordered_tables_escape_before_shared_unicode_alignment() {
307        let result = render_bordered(
308            &["界", "h\n"],
309            &[
310                vec!["e\u{301}", "\x1b]52;c;x\x07"],
311                vec!["x\ny", "\u{202e}\u{0085}\u{2028}"],
312            ],
313        );
314        let lines = result.lines().collect::<Vec<_>>();
315        assert_eq!(lines.len(), 6);
316        assert!(!result.contains('\x1b'));
317        assert!(!result.contains('\u{202e}'));
318        assert!(result.contains("e\u{301}"));
319        assert!(result.contains("x\\ny"));
320        assert!(result.contains("h\\n"));
321        let width = display_width(lines[0]);
322        for line in lines {
323            assert_eq!(display_width(line), width);
324        }
325        assert_eq!(
326            render_bordered(&["X"], &[vec!["👩‍💻"]]),
327            "+------+\n| X    |\n+------+\n| 👩‍💻 |\n+------+"
328        );
329    }
330}