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;
3use std::borrow::Cow;
4use unicode_width::UnicodeWidthStr;
5
6/// Caller-selected alignment of one complete column.
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum Alignment {
9    /// Pad after the cell.
10    Left,
11    /// Pad before the cell.
12    Right,
13}
14
15/// Explicit formatting choices; no ambient terminal policy or defaults.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct Options {
18    /// Spaces between columns.
19    pub column_gap: usize,
20    /// Whether a dashed line follows the headers.
21    pub header_separator: bool,
22}
23
24/// Render headers and rows after escaping every cell, including headers.
25///
26/// Widths use Unicode terminal display columns, not byte or character counts.
27/// Rows have the same column count by type. Header/data lines omit trailing
28/// whitespace; the result has no final newline. Zero columns return empty text.
29/// Caller owns input/output size, row construction, headers, column choices,
30/// truncation and all output, terminal detection, colour and progress behavior.
31/// Storage allocation follows ordinary String/Vec allocation semantics.
32#[must_use]
33pub fn render<const N: usize>(
34    headers: &[impl AsRef<str>; N],
35    rows: &[[impl AsRef<str>; N]],
36    alignments: &[Alignment; N],
37    options: Options,
38) -> String {
39    if N == 0 {
40        return String::new();
41    }
42    let headers = headers.each_ref().map(|cell| terminal_text(cell.as_ref()));
43    let rows = rows
44        .iter()
45        .map(|row| row.each_ref().map(|cell| terminal_text(cell.as_ref())))
46        .collect::<Vec<_>>();
47    let widths = std::array::from_fn(|index| {
48        rows.iter().fold(
49            UnicodeWidthStr::width(headers[index].as_ref()),
50            |width, row| width.max(UnicodeWidthStr::width(row[index].as_ref())),
51        )
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
72fn render_row<const N: usize>(
73    row: &[Cow<'_, str>; N],
74    widths: &[usize; N],
75    alignments: &[Alignment; N],
76    gap: &str,
77    output: &mut String,
78) {
79    let start = output.len();
80    for (index, ((value, width), alignment)) in row.iter().zip(widths).zip(alignments).enumerate() {
81        if index != 0 {
82            output.push_str(gap);
83        }
84        let padding = width.saturating_sub(UnicodeWidthStr::width(value.as_ref()));
85        if *alignment == Alignment::Right {
86            output.extend(std::iter::repeat_n(' ', padding));
87        }
88        output.push_str(value);
89        if *alignment == Alignment::Left {
90            output.extend(std::iter::repeat_n(' ', padding));
91        }
92    }
93    // Trim only this row so empty cells cannot erase earlier line separators.
94    output.truncate(start + output[start..].trim_end().len());
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    const OPTIONS: Options = Options {
101        column_gap: 2,
102        header_separator: true,
103    };
104
105    #[test]
106    fn wide_and_combining_cells_align_in_display_columns() {
107        assert_eq!(
108            render(
109                &["NAME", "N"],
110                &[["界", "7"], ["e\u{301}", "123"]],
111                &[Alignment::Left, Alignment::Right],
112                OPTIONS
113            ),
114            "NAME    N\n----  ---\n界      7\ne\u{301}     123"
115        );
116    }
117
118    #[test]
119    fn escaped_headers_and_cells_are_measured_and_stay_on_their_rows() {
120        let text = render(
121            &["H\n"],
122            &[["x\x1b[2J\t\u{2029}"]],
123            &[Alignment::Left],
124            OPTIONS,
125        );
126        assert_eq!(text.lines().count(), 3);
127        assert!(text.starts_with("H\\n\n"));
128        assert!(text.ends_with("x\\u{1b}[2J\\t\\u{2029}"));
129        assert!(!text.contains('\x1b'));
130    }
131
132    #[test]
133    fn whitespace_cells_preserve_empty_rows_and_separator_spacing() {
134        assert_eq!(
135            render(
136                &["  ", "B "],
137                &[["", ""], ["x ", "\u{2003}"], ["界", "7"]],
138                &[Alignment::Left, Alignment::Right],
139                OPTIONS,
140            ),
141            "    B\n--  --\n\nx\n界   7"
142        );
143        assert_eq!(
144            render(&["H"], &[[""], [""]], &[Alignment::Left], OPTIONS),
145            "H\n-\n\n"
146        );
147        assert_eq!(
148            render(&["", ""], &[["", ""]], &[Alignment::Left; 2], OPTIONS),
149            "\n  \n"
150        );
151    }
152
153    #[test]
154    fn explicit_options_and_empty_tables_have_no_hidden_defaults() {
155        assert_eq!(
156            render(
157                &["A", "B"],
158                &[["1", "2"]],
159                &[Alignment::Left; 2],
160                Options {
161                    column_gap: 0,
162                    header_separator: false
163                }
164            ),
165            "AB\n12"
166        );
167        assert_eq!(
168            render(&["A"], &[] as &[[&str; 1]], &[Alignment::Left], OPTIONS),
169            "A\n-"
170        );
171        assert_eq!(
172            render(&[] as &[&str; 0], &[] as &[[&str; 0]], &[], OPTIONS),
173            ""
174        );
175    }
176}