Skip to main content

pimalaya_cli/
columns.rs

1//! Text lists printed as aligned columns.
2//!
3//! A command printing a list as text renders it as [`Columns`]: a header
4//! row of labels, then one row per item, the cells aligned in columns
5//! two spaces apart, with no border and no colour. Each line is one item,
6//! greppable and split on blanks, and the list ends with a newline like
7//! every text output. [`Fields`] is its twin for a single record, and
8//! [`Records`] its counterpart for items too wide for one line.
9//!
10//! [`Fields`]: crate::fields::Fields
11//! [`Records`]: crate::records::Records
12
13use std::{borrow::Cow, fmt, iter};
14
15use unicode_width::UnicodeWidthStr;
16
17/// A list printed as borderless aligned columns.
18///
19/// Cells are padded to the widest cell of their column, measured in
20/// terminal display columns, and separated by two spaces; the last column
21/// is not padded. An empty or missing cell prints as `-`, and a line break
22/// inside a cell as a space. Nothing is truncated: the terminal folds a
23/// long line. Labels and cells print as given: the caller passes the
24/// labels uppercase, and sanitizes an untrusted cell first with
25/// [`sanitize::text`].
26///
27/// [`sanitize::text`]: crate::sanitize::text
28#[derive(Clone, Debug, Default, Eq, PartialEq)]
29pub struct Columns<'a> {
30    /// The column labels, printed as the header row unless empty.
31    pub header: Vec<&'a str>,
32    /// The rendered cells of each row, in print order.
33    pub rows: Vec<Vec<String>>,
34}
35
36impl<'a> Columns<'a> {
37    /// Builds a list of the given column labels, without rows.
38    pub fn new(header: impl IntoIterator<Item = &'a str>) -> Self {
39        Self {
40            header: header.into_iter().collect(),
41            rows: Vec::new(),
42        }
43    }
44
45    /// Appends a row, rendering its cells.
46    pub fn push(&mut self, row: impl IntoIterator<Item = impl fmt::Display>) {
47        let row = row.into_iter().map(|cell| cell.to_string());
48        self.rows.push(row.collect());
49    }
50}
51
52impl fmt::Display for Columns<'_> {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        let header = (!self.header.is_empty()).then(|| {
55            let labels = self.header.iter().copied();
56            labels.map(cell).collect::<Vec<_>>()
57        });
58        let rows = self.rows.iter().map(|row| {
59            let cells = row.iter().map(String::as_str);
60            cells.map(cell).collect::<Vec<_>>()
61        });
62        let lines: Vec<_> = iter::once(header).flatten().chain(rows).collect();
63
64        let count = lines.iter().map(Vec::len).max().unwrap_or_default();
65        let mut widths = vec![1; count];
66
67        for line in &lines {
68            for (width, cell) in widths.iter_mut().zip(line) {
69                *width = cell.width().max(*width);
70            }
71        }
72
73        for line in &lines {
74            for (i, width) in widths.iter().enumerate() {
75                let cell = line.get(i).map_or("-", |cell| cell);
76
77                if i > 0 {
78                    f.write_str("  ")?;
79                }
80
81                f.write_str(cell)?;
82
83                if i + 1 < count {
84                    let pad = width - cell.width();
85                    write!(f, "{:pad$}", "")?;
86                }
87            }
88
89            writeln!(f)?;
90        }
91
92        Ok(())
93    }
94}
95
96/// Renders a cell on one line, `-` when empty.
97fn cell(s: &str) -> Cow<'_, str> {
98    if s.is_empty() {
99        Cow::Borrowed("-")
100    } else if s.contains(['\n', '\r']) {
101        Cow::Owned(s.replace(['\n', '\r'], " "))
102    } else {
103        Cow::Borrowed(s)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use crate::columns::*;
110
111    #[test]
112    fn a_wide_last_column_is_not_padded() {
113        let mut columns = Columns::new(["ID", "NAME", "DESCRIPTION"]);
114        columns.push(["1", "Inbox", "Mail received"]);
115        columns.push(["42", "Sent", ""]);
116
117        let expected = "\
118ID  NAME   DESCRIPTION
1191   Inbox  Mail received
12042  Sent   -
121";
122        assert_eq!(columns.to_string(), expected);
123    }
124
125    #[test]
126    fn wide_characters_count_their_display_width() {
127        let mut columns = Columns::new(["NAME", "ID"]);
128        columns.push(["日本語", "1"]);
129        columns.push(["🦀", "2"]);
130        columns.push(["ab", "3"]);
131
132        let expected = "\
133NAME    ID
134日本語  1
135🦀      2
136ab      3
137";
138        assert_eq!(columns.to_string(), expected);
139    }
140
141    #[test]
142    fn empty_and_missing_cells_print_a_dash() {
143        let mut columns = Columns::new(["A", "B", "C"]);
144        columns.push(["", "b"]);
145        columns.push(["a", "", "c"]);
146
147        let expected = "\
148A  B  C
149-  b  -
150a  -  c
151";
152        assert_eq!(columns.to_string(), expected);
153    }
154
155    #[test]
156    fn a_line_break_in_a_cell_prints_as_a_space() {
157        let mut columns = Columns::new(["SUBJECT", "FROM"]);
158        columns.push(["Re:\nhello", "a\r\nb"]);
159
160        let expected = "\
161SUBJECT    FROM
162Re: hello  a  b
163";
164        assert_eq!(columns.to_string(), expected);
165    }
166
167    #[test]
168    fn a_list_without_rows_prints_its_header() {
169        let columns = Columns::new(["ID", "NAME"]);
170        assert_eq!(columns.to_string(), "ID  NAME\n");
171        assert_eq!(Columns::default().to_string(), "");
172    }
173}