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 unicode_width::UnicodeWidthStr;
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 use Unicode terminal display columns, not byte or character counts.
26/// Rows have the same column count by type. Header/data lines omit trailing
27/// whitespace; the result has no final newline. Zero columns return empty text.
28/// Caller owns input/output size, row construction, headers, column choices,
29/// truncation and all output, terminal detection, colour and progress behavior.
30/// Storage allocation follows ordinary String/Vec allocation semantics.
31#[must_use]
32pub fn render<const N: usize>(
33    headers: &[impl AsRef<str>; N],
34    rows: &[[impl AsRef<str>; N]],
35    alignments: &[Alignment; N],
36    options: Options,
37) -> String {
38    if N == 0 {
39        return String::new();
40    }
41    let headers = headers.each_ref().map(|cell| terminal_text(cell.as_ref()));
42    let rows = rows
43        .iter()
44        .map(|row| row.each_ref().map(|cell| terminal_text(cell.as_ref())))
45        .collect::<Vec<_>>();
46    let widths = std::array::from_fn(|index| {
47        rows.iter().fold(
48            UnicodeWidthStr::width(headers[index].as_str()),
49            |width, row| width.max(UnicodeWidthStr::width(row[index].as_str())),
50        )
51    });
52    let gap = " ".repeat(options.column_gap);
53    let mut lines = vec![render_row(&headers, &widths, alignments, &gap)];
54    if options.header_separator {
55        lines.push(
56            widths
57                .iter()
58                .map(|width| "-".repeat(*width))
59                .collect::<Vec<_>>()
60                .join(&gap),
61        );
62    }
63    lines.extend(
64        rows.iter()
65            .map(|row| render_row(row, &widths, alignments, &gap)),
66    );
67    lines.join("\n")
68}
69
70fn render_row<const N: usize>(
71    row: &[String; N],
72    widths: &[usize; N],
73    alignments: &[Alignment; N],
74    gap: &str,
75) -> String {
76    row.iter()
77        .zip(widths)
78        .zip(alignments)
79        .map(|((value, width), alignment)| {
80            let padding = " ".repeat(width.saturating_sub(UnicodeWidthStr::width(value.as_str())));
81            match alignment {
82                Alignment::Left => format!("{value}{padding}"),
83                Alignment::Right => format!("{padding}{value}"),
84            }
85        })
86        .collect::<Vec<_>>()
87        .join(gap)
88        .trim_end()
89        .to_string()
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    const OPTIONS: Options = Options {
96        column_gap: 2,
97        header_separator: true,
98    };
99
100    #[test]
101    fn wide_and_combining_cells_align_in_display_columns() {
102        assert_eq!(
103            render(
104                &["NAME", "N"],
105                &[["界", "7"], ["e\u{301}", "123"]],
106                &[Alignment::Left, Alignment::Right],
107                OPTIONS
108            ),
109            "NAME    N\n----  ---\n界      7\ne\u{301}     123"
110        );
111    }
112
113    #[test]
114    fn escaped_headers_and_cells_are_measured_and_stay_on_their_rows() {
115        let text = render(
116            &["H\n"],
117            &[["x\x1b[2J\t\u{2029}"]],
118            &[Alignment::Left],
119            OPTIONS,
120        );
121        assert_eq!(text.lines().count(), 3);
122        assert!(text.starts_with("H\\n\n"));
123        assert!(text.ends_with("x\\u{1b}[2J\\t\\u{2029}"));
124        assert!(!text.contains('\x1b'));
125    }
126
127    #[test]
128    fn explicit_options_and_empty_tables_have_no_hidden_defaults() {
129        assert_eq!(
130            render(
131                &["A", "B"],
132                &[["1", "2"]],
133                &[Alignment::Left; 2],
134                Options {
135                    column_gap: 0,
136                    header_separator: false
137                }
138            ),
139            "AB\n12"
140        );
141        assert_eq!(
142            render(&["A"], &[] as &[[&str; 1]], &[Alignment::Left], OPTIONS),
143            "A\n-"
144        );
145        assert_eq!(
146            render(&[] as &[&str; 0], &[] as &[[&str; 0]], &[], OPTIONS),
147            ""
148        );
149    }
150}