ic_host_text/table/
mod.rs1use crate::escape::terminal_text;
3use unicode_width::UnicodeWidthStr;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum Alignment {
8 Left,
10 Right,
12}
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub struct Options {
17 pub column_gap: usize,
19 pub header_separator: bool,
21}
22
23#[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}