1use crate::{escape::terminal_text, width::display_width};
3use std::borrow::Cow;
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]
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#[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 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 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 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}