alixt_table/
table.rs

1use colored::{ColoredString, Colorize};
2
3use crate::TableError;
4
5pub const TOP_LEFT: &str = "╭";
6pub const TOP_RIGHT: &str = "╮";
7pub const BOTTOM_LEFT: &str = "╰";
8pub const BOTTOM_RIGHT: &str = "╯";
9pub const HORIZONTAL: &str = "─";
10pub const VERTICAL: &str = "│";
11pub const CROSS: &str = "┼";
12pub const TOP_T: &str = "┬";
13pub const BOTTOM_T: &str = "┴";
14pub const LEFT_T: &str = "├";
15pub const RIGHT_T: &str = "┤";
16
17pub const SPACES: &str = "                                                                                                                               ";
18pub enum TableResult<const N: usize> {
19    Table(Table<N>),
20    TableError(TableError),
21}
22
23impl<const N: usize> TableResult<N> {
24    /// Example:
25    /// let Table::new().title("My table".red())
26    pub fn title(self, title: ColoredString) -> Self {
27        let has_bad_char = title.chars().any(|c| c.is_ascii_control() || !c.is_ascii());
28        match self {
29            Self::Table(_) if has_bad_char => Self::TableError(TableError::InputError(
30                "title contians non-ascii character or ascii control character".to_string(),
31            )),
32            Self::Table(mut table) => {
33                table.title = title;
34                Self::Table(table)
35            }
36            Self::TableError(err) => Self::TableError(err),
37        }
38    }
39
40    pub fn headers(self, headers: [ColoredString; N]) -> Self {
41        match self {
42            Self::Table(mut table) => {
43                for (i, header) in headers.into_iter().enumerate() {
44                    if header
45                        .chars()
46                        .any(|c| !c.is_ascii() || c.is_ascii_control())
47                    {
48                        return Self::TableError(TableError::InputError(format!(
49                            "header contains invalid header, '{}', bad character",
50                            header
51                        )));
52                    }
53                    if header.len() > table.col_widths[i] {
54                        table.col_widths[i] = header.len();
55                    }
56                    table.headers[i] = header;
57                }
58                Self::Table(table)
59            }
60            err => err,
61        }
62    }
63
64    pub fn row(self, row: [ColoredString; N]) -> Self {
65        match self {
66            Self::Table(mut table) => {
67                for (i, cell) in row.into_iter().enumerate() {
68                    if cell.chars().any(|c| !c.is_ascii() || c.is_ascii_control()) {
69                        return Self::TableError(TableError::InputError(format!(
70                            "row contains invalid cell, '{cell}', bad character"
71                        )));
72                    }
73                    if cell.len() > table.col_widths[i] {
74                        table.col_widths[i] = cell.len();
75                    }
76                    table.cells.push(cell);
77                }
78                table.rows += 1;
79                TableResult::Table(table)
80            }
81            err => err,
82        }
83    }
84
85    pub fn collect(self) -> Result<Table<N>, TableError> {
86        match self {
87            Self::Table(table) => Ok(table),
88            Self::TableError(err) => Err(err),
89        }
90    }
91}
92
93pub struct Table<const N: usize> {
94    title: ColoredString,
95    rows: usize,
96    col_widths: [usize; N],
97    headers: [ColoredString; N],
98    cells: Vec<ColoredString>,
99}
100
101impl<const N: usize> Table<N> {
102    /// Table is created with a fixed number of columns
103    #[allow(clippy::new_ret_no_self)]
104    pub fn new() -> TableResult<N> {
105        TableResult::Table(Self {
106            title: "".white(),
107            rows: 0,
108            col_widths: [0; N],
109            headers: std::array::from_fn(|_| "".white()),
110            cells: Vec::new(),
111        })
112    }
113    pub fn headers(&mut self, headers: [ColoredString; N]) -> Result<(), TableError> {
114        for header in headers.iter() {
115            if header
116                .chars()
117                .any(|c| !c.is_ascii() || c.is_ascii_control())
118            {
119                return Err(TableError::InputError(format!(
120                    "Invalid header '{header}' contains non-ascii or control char"
121                )));
122            }
123        }
124        for (i, header) in headers.into_iter().enumerate() {
125            if header.len() > self.col_widths[i] {
126                self.col_widths[i] = header.len();
127            }
128            self.headers[i] = header;
129        }
130        Ok(())
131    }
132
133    pub fn push_row(&mut self, row: [ColoredString; N]) -> Result<(), TableError> {
134        for cell in row.iter() {
135            if cell.chars().any(|c| !c.is_ascii() || c.is_ascii_control()) {
136                return Err(TableError::InputError(format!(
137                    "Invalid row cell, '{cell}', contains non-ascii or control char"
138                )));
139            }
140        }
141        for (i, cell) in row.into_iter().enumerate() {
142            if cell.len() > self.col_widths[i] {
143                self.col_widths[i] = cell.len();
144            }
145            self.cells.push(cell);
146        }
147        self.rows += 1;
148        Ok(())
149    }
150
151    pub fn get_title(&self) -> Option<&ColoredString> {
152        if self.title.is_empty() {
153            None
154        } else {
155            Some(&self.title)
156        }
157    }
158
159    pub fn get_row_count(&self) -> usize {
160        self.rows
161    }
162
163    pub fn get_column_count(&self) -> usize {
164        N
165    }
166
167    pub fn get_headers(&self) -> &[ColoredString] {
168        &self.headers
169    }
170
171    pub fn get_col_widths(&self) -> &[usize] {
172        &self.col_widths
173    }
174
175    pub fn get_row(&self, index: usize) -> Option<&[ColoredString]> {
176        if index >= self.rows {
177            return None;
178        }
179        Some(&self.cells[index * N..(index * N) + N])
180    }
181
182    pub fn padding(&self, spacing: usize) -> &str {
183        &SPACES[..std::cmp::min(spacing, SPACES.len())]
184    }
185
186    pub fn content_width(&self) -> usize {
187        self.get_col_widths().iter().sum()
188    }
189
190    fn _render_top<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
191        let table_width = self.content_width() + self.get_column_count() - 1;
192
193        writeln!(
194            w,
195            "\n{}{}{}",
196            TOP_LEFT.blue(),
197            HORIZONTAL.repeat(table_width).blue(),
198            TOP_RIGHT.blue()
199        )?;
200
201        Ok(())
202    }
203
204    fn _render_title<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
205        let table_width = self.content_width() + self.get_column_count() - 1;
206
207        if let Some(title) = self.get_title() {
208            write!(w, "{}", VERTICAL.blue())?;
209            // if title is longer than table width, truncate to be 5 chars shorter
210            // than the table, to account for the border chars and the '...'
211            if title.len() > table_width - 2 {
212                writeln!(
213                    w,
214                    "{}...{}",
215                    self.title[..table_width - 3]
216                        .to_string()
217                        .color(self.title.fgcolor.unwrap_or(colored::Color::White)),
218                    VERTICAL.blue(),
219                )?;
220            } else {
221                let pad_l = self.padding((table_width - title.len()) / 2);
222                let pad_r =
223                    self.padding((table_width - title.len()) - (table_width - title.len()) / 2);
224
225                writeln!(w, "{}{}{}{}", pad_l, self.title, pad_r, VERTICAL.blue(),)?;
226            }
227        }
228        Ok(())
229    }
230
231    fn _render_headers<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
232        write!(
233            w,
234            "{}{}",
235            LEFT_T.blue(),
236            HORIZONTAL.repeat(self.get_col_widths()[0]).blue()
237        )?;
238        for i in 1..self.get_column_count() {
239            write!(
240                w,
241                "{}{}",
242                TOP_T.blue(),
243                HORIZONTAL.repeat(self.get_col_widths()[i]).blue()
244            )?;
245        }
246        writeln!(w, "{}", RIGHT_T.blue())?;
247        write!(w, "{}", VERTICAL.blue())?;
248        for (i, header) in self.get_headers().iter().enumerate() {
249            write!(
250                w,
251                "{}{}{}",
252                header,
253                self.padding(self.get_col_widths()[i] - header.len()),
254                VERTICAL.blue()
255            )?;
256        }
257        writeln!(w)?;
258        Ok(())
259    }
260
261    fn _render_row<W: std::io::Write>(&self, w: &mut W, row: usize) -> Result<(), TableError> {
262        write!(
263            w,
264            "{}{}",
265            LEFT_T.blue(),
266            HORIZONTAL.repeat(self.get_col_widths()[0]).blue()
267        )?;
268        for i in 1..self.get_column_count() {
269            write!(
270                w,
271                "{}{}",
272                CROSS.blue(),
273                HORIZONTAL.repeat(self.get_col_widths()[i]).blue()
274            )?;
275        }
276        writeln!(w, "{}", RIGHT_T.blue())?;
277        write!(w, "{}", VERTICAL.blue())?;
278        let Some(row_data) = self.get_row(row) else {
279            return Err(TableError::InputError(
280                "invalid row index given".to_string(),
281            ));
282        };
283        for (i, cell) in row_data.iter().enumerate() {
284            write!(
285                w,
286                "{}{}{}",
287                cell,
288                self.padding(self.get_col_widths()[i] - cell.len()),
289                VERTICAL.blue()
290            )?;
291        }
292        writeln!(w)?;
293        Ok(())
294    }
295
296    fn _render_bottom<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
297        write!(
298            w,
299            "{}{}",
300            BOTTOM_LEFT.blue(),
301            HORIZONTAL.repeat(self.get_col_widths()[0]).blue()
302        )?;
303        for i in 1..self.get_column_count() {
304            write!(
305                w,
306                "{}{}",
307                BOTTOM_T.blue(),
308                HORIZONTAL.repeat(self.get_col_widths()[i]).blue()
309            )?;
310        }
311        writeln!(w, "{}", BOTTOM_RIGHT.blue())?;
312        Ok(())
313    }
314
315    pub fn render<W: std::io::Write>(&self, w: &mut W) -> Result<(), TableError> {
316        self._render_top(w)?;
317        self._render_title(w)?;
318        self._render_headers(w)?;
319        for row in 0..self.get_row_count() {
320            self._render_row(w, row)?;
321        }
322        self._render_bottom(w)?;
323        Ok(())
324    }
325}