radicle_term/
table.rs

1//! Print column-aligned text to the console.
2//!
3//! Example:
4//! ```
5//! use radicle_term::table::*;
6//!
7//! let mut t = Table::new(TableOptions::default());
8//! t.push(["pest", "biological control"]);
9//! t.push(["aphid", "lacewing"]);
10//! t.push(["spider mite", "ladybug"]);
11//! t.print();
12//! ```
13//! Output:
14//! ``` plain
15//! pest        biological control
16//! aphid       ladybug
17//! spider mite persimilis
18//! ```
19use std::fmt;
20
21use crate::cell::Cell;
22use crate::{self as term, Style};
23use crate::{Color, Constraint, Line, Paint, Size};
24
25pub use crate::Element;
26
27#[derive(Debug)]
28pub struct TableOptions {
29    /// Whether the table should be allowed to overflow.
30    pub overflow: bool,
31    /// Horizontal spacing between table cells.
32    pub spacing: usize,
33    /// Table border.
34    pub border: Option<Color>,
35}
36
37impl Default for TableOptions {
38    fn default() -> Self {
39        Self {
40            overflow: false,
41            spacing: 1,
42            border: None,
43        }
44    }
45}
46
47impl TableOptions {
48    pub fn bordered() -> Self {
49        Self {
50            border: Some(term::colors::FAINT),
51            spacing: 3,
52            ..Self::default()
53        }
54    }
55}
56
57#[derive(Debug)]
58enum Row<const W: usize, T> {
59    Header([T; W]),
60    Data([T; W]),
61    Divider,
62}
63
64#[derive(Debug)]
65pub struct Table<const W: usize, T> {
66    rows: Vec<Row<W, T>>,
67    widths: [usize; W],
68    opts: TableOptions,
69}
70
71impl<const W: usize, T> Default for Table<W, T> {
72    fn default() -> Self {
73        Self {
74            rows: Vec::new(),
75            widths: [0; W],
76            opts: TableOptions::default(),
77        }
78    }
79}
80
81impl<const W: usize, T: Cell> FromIterator<[T; W]> for Table<W, T> {
82    fn from_iter<I: IntoIterator<Item = [T; W]>>(iter: I) -> Self {
83        let mut table = Self::default();
84        table.rows.extend(iter.into_iter().map(|row| {
85            for (i, cell) in row.iter().enumerate() {
86                table.widths[i] = table.widths[i].max(cell.width());
87            }
88            Row::Data(row)
89        }));
90        table
91    }
92}
93
94impl<const W: usize, T: Cell + fmt::Debug + Send + Sync> Element for Table<W, T>
95where
96    T::Padded: Into<Line>,
97{
98    fn size(&self, parent: Constraint) -> Size {
99        Table::size(self, parent)
100    }
101
102    fn render(&self, parent: Constraint) -> Vec<Line> {
103        let mut lines = Vec::new();
104        let border = self.opts.border;
105        let inner = self.inner(parent);
106        let cols = inner.cols;
107
108        // Don't print empty tables.
109        if self.is_empty() {
110            return lines;
111        }
112
113        if let Some(color) = border {
114            lines.push(
115                Line::default()
116                    .item(Paint::new("╭").fg(color))
117                    .item(Paint::new("─".repeat(cols)).fg(color))
118                    .item(Paint::new("╮").fg(color)),
119            );
120        }
121
122        for row in &self.rows {
123            let mut line = Line::default();
124
125            match row {
126                Row::Header(cells) | Row::Data(cells) => {
127                    if let Some(color) = border {
128                        line.push(Paint::new("│ ").fg(color));
129                    }
130                    for (i, cell) in cells.iter().enumerate() {
131                        let pad = if i == cells.len() - 1 {
132                            0
133                        } else {
134                            self.widths[i] + self.opts.spacing
135                        };
136                        line = line.extend(
137                            cell.pad(pad)
138                                .into()
139                                .style(Style::default().bg(cell.background())),
140                        );
141                    }
142                    Line::pad(&mut line, cols);
143                    Line::truncate(&mut line, cols, "…");
144
145                    if let Some(color) = border {
146                        line.push(Paint::new(" │").fg(color));
147                    }
148                    lines.push(line);
149                }
150                Row::Divider => {
151                    if let Some(color) = border {
152                        lines.push(
153                            Line::default()
154                                .item(Paint::new("├").fg(color))
155                                .item(Paint::new("─".repeat(cols)).fg(color))
156                                .item(Paint::new("┤").fg(color)),
157                        );
158                    } else {
159                        lines.push(Line::default());
160                    }
161                }
162            }
163        }
164        if let Some(color) = border {
165            lines.push(
166                Line::default()
167                    .item(Paint::new("╰").fg(color))
168                    .item(Paint::new("─".repeat(cols)).fg(color))
169                    .item(Paint::new("╯").fg(color)),
170            );
171        }
172        lines
173    }
174}
175
176impl<const W: usize, T: Cell> Table<W, T> {
177    pub fn new(opts: TableOptions) -> Self {
178        Self {
179            rows: Vec::new(),
180            widths: [0; W],
181            opts,
182        }
183    }
184
185    pub fn with_opts(mut self, opts: TableOptions) -> Self {
186        self.opts = opts;
187        self
188    }
189
190    pub fn size(&self, parent: Constraint) -> Size {
191        self.outer(parent)
192    }
193
194    pub fn divider(&mut self) {
195        self.rows.push(Row::Divider);
196    }
197
198    pub fn push(&mut self, row: [T; W]) {
199        for (i, cell) in row.iter().enumerate() {
200            self.widths[i] = self.widths[i].max(cell.width());
201        }
202        self.rows.push(Row::Data(row));
203    }
204
205    pub fn header(&mut self, row: [T; W]) {
206        for (i, cell) in row.iter().enumerate() {
207            self.widths[i] = self.widths[i].max(cell.width());
208        }
209        self.rows.push(Row::Header(row));
210    }
211
212    pub fn is_empty(&self) -> bool {
213        !self.rows.iter().any(|r| matches!(r, Row::Data { .. }))
214    }
215
216    fn inner(&self, c: Constraint) -> Size {
217        let mut outer = self.outer(c);
218
219        if self.opts.border.is_some() {
220            outer.cols -= 2;
221            outer.rows -= 2;
222        }
223        outer
224    }
225
226    fn outer(&self, c: Constraint) -> Size {
227        let mut cols = self.widths.iter().sum::<usize>() + (W - 1) * self.opts.spacing;
228        let mut rows = self.rows.len();
229        let padding = 2;
230
231        // Account for outer borders.
232        if self.opts.border.is_some() {
233            cols += 2 + padding;
234            rows += 2;
235        }
236        Size::new(cols, rows).constrain(c)
237    }
238}
239
240impl<const W: usize, T: Cell> Extend<[T; W]> for Table<W, T> {
241    fn extend<I>(&mut self, iter: I)
242    where
243        I: IntoIterator<Item = [T; W]>,
244    {
245        self.rows.extend(iter.into_iter().map(|row| {
246            for (i, cell) in row.iter().enumerate() {
247                self.widths[i] = self.widths[i].max(cell.width());
248            }
249            Row::Data(row)
250        }));
251    }
252}
253
254#[cfg(test)]
255mod test {
256    use crate::Element;
257
258    use super::*;
259    use pretty_assertions::assert_eq;
260
261    #[test]
262    fn test_truncate() {
263        assert_eq!("🍍".truncate(1, "…"), String::from("…"));
264        assert_eq!("🍍".truncate(1, ""), String::from(""));
265        assert_eq!("🍍🍍".truncate(2, "…"), String::from("…"));
266        assert_eq!("🍍🍍".truncate(3, "…"), String::from("🍍…"));
267        assert_eq!("🍍".truncate(1, "🍎"), String::from(""));
268        assert_eq!("🍍".truncate(2, "🍎"), String::from("🍍"));
269        assert_eq!("🍍🍍".truncate(3, "🍎"), String::from("🍎"));
270        assert_eq!("🍍🍍🍍".truncate(4, "🍎"), String::from("🍍🍎"));
271        assert_eq!("hello".truncate(3, "…"), String::from("he…"));
272    }
273
274    #[test]
275    fn test_table() {
276        let mut t = Table::new(TableOptions::default());
277
278        t.push(["pineapple", "rosemary"]);
279        t.push(["apples", "pears"]);
280
281        #[rustfmt::skip]
282        assert_eq!(
283            t.display(Constraint::UNBOUNDED),
284            [
285                "pineapple rosemary\n",
286                "apples    pears   \n"
287            ].join("")
288        );
289    }
290
291    #[test]
292    fn test_table_border() {
293        let mut t = Table::new(TableOptions {
294            border: Some(Color::Unset),
295            spacing: 3,
296            ..TableOptions::default()
297        });
298
299        t.push(["Country", "Population", "Code"]);
300        t.divider();
301        t.push(["France", "60M", "FR"]);
302        t.push(["Switzerland", "7M", "CH"]);
303        t.push(["Germany", "80M", "DE"]);
304
305        let inner = t.inner(Constraint::UNBOUNDED);
306        assert_eq!(inner.cols, 33);
307        assert_eq!(inner.rows, 5);
308
309        let outer = t.outer(Constraint::UNBOUNDED);
310        assert_eq!(outer.cols, 35);
311        assert_eq!(outer.rows, 7);
312
313        assert_eq!(
314            t.display(Constraint::UNBOUNDED),
315            r#"
316╭─────────────────────────────────╮
317│ Country       Population   Code │
318├─────────────────────────────────┤
319│ France        60M          FR   │
320│ Switzerland   7M           CH   │
321│ Germany       80M          DE   │
322╰─────────────────────────────────╯
323"#
324            .trim_start()
325        );
326    }
327
328    #[test]
329    fn test_table_border_truncated() {
330        let mut t = Table::new(TableOptions {
331            border: Some(Color::Unset),
332            spacing: 3,
333            ..TableOptions::default()
334        });
335
336        t.push(["Code", "Name"]);
337        t.divider();
338        t.push(["FR", "France"]);
339        t.push(["CH", "Switzerland"]);
340        t.push(["DE", "Germany"]);
341
342        let constrain = Constraint::max(Size {
343            cols: 19,
344            rows: usize::MAX,
345        });
346        let outer = t.outer(constrain);
347        assert_eq!(outer.cols, 19);
348        assert_eq!(outer.rows, 7);
349
350        let inner = t.inner(constrain);
351        assert_eq!(inner.cols, 17);
352        assert_eq!(inner.rows, 5);
353
354        assert_eq!(
355            t.display(constrain),
356            r#"
357╭─────────────────╮
358│ Code   Name     │
359├─────────────────┤
360│ FR     France   │
361│ CH     Switzer… │
362│ DE     Germany  │
363╰─────────────────╯
364"#
365            .trim_start()
366        );
367    }
368
369    #[test]
370    fn test_table_border_maximized() {
371        let mut t = Table::new(TableOptions {
372            border: Some(Color::Unset),
373            spacing: 3,
374            ..TableOptions::default()
375        });
376
377        t.push(["Code", "Name"]);
378        t.divider();
379        t.push(["FR", "France"]);
380        t.push(["CH", "Switzerland"]);
381        t.push(["DE", "Germany"]);
382
383        let constrain = Constraint::new(
384            Size { cols: 26, rows: 0 },
385            Size {
386                cols: 26,
387                rows: usize::MAX,
388            },
389        );
390        let outer = t.outer(constrain);
391        assert_eq!(outer.cols, 26);
392        assert_eq!(outer.rows, 7);
393
394        let inner = t.inner(constrain);
395        assert_eq!(inner.cols, 24);
396        assert_eq!(inner.rows, 5);
397
398        assert_eq!(
399            t.display(constrain),
400            r#"
401╭────────────────────────╮
402│ Code   Name            │
403├────────────────────────┤
404│ FR     France          │
405│ CH     Switzerland     │
406│ DE     Germany         │
407╰────────────────────────╯
408"#
409            .trim_start()
410        );
411    }
412
413    #[test]
414    fn test_table_truncate() {
415        let mut t = Table::default();
416        let constrain = Constraint::new(
417            Size::MIN,
418            Size {
419                cols: 16,
420                rows: usize::MAX,
421            },
422        );
423
424        t.push(["pineapple", "rosemary"]);
425        t.push(["apples", "pears"]);
426
427        #[rustfmt::skip]
428        assert_eq!(
429            t.display(constrain),
430            [
431                "pineapple rosem…\n",
432                "apples    pears \n"
433            ].join("")
434        );
435    }
436
437    #[test]
438    fn test_table_unicode() {
439        let mut t = Table::new(TableOptions::default());
440
441        t.push(["🍍pineapple", "__rosemary", "__sage"]);
442        t.push(["__pears", "🍎apples", "🍌bananas"]);
443
444        #[rustfmt::skip]
445        assert_eq!(
446            t.display(Constraint::UNBOUNDED),
447            [
448                "🍍pineapple __rosemary __sage   \n",
449                "__pears     🍎apples   🍌bananas\n"
450            ].join("")
451        );
452    }
453
454    #[test]
455    fn test_table_unicode_truncate() {
456        let mut t = Table::new(TableOptions {
457            ..TableOptions::default()
458        });
459        let constrain = Constraint::max(Size {
460            cols: 16,
461            rows: usize::MAX,
462        });
463        t.push(["🍍pineapple", "__rosemary"]);
464        t.push(["__pears", "🍎apples"]);
465
466        #[rustfmt::skip]
467        assert_eq!(
468            t.display(constrain),
469            [
470                "🍍pineapple __r…\n",
471                "__pears     🍎a…\n"
472            ].join("")
473        );
474    }
475}