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 + fmt::Debug + Send + Sync> Element for Table<W, T>
82where
83    T::Padded: Into<Line>,
84{
85    fn size(&self, parent: Constraint) -> Size {
86        Table::size(self, parent)
87    }
88
89    fn render(&self, parent: Constraint) -> Vec<Line> {
90        let mut lines = Vec::new();
91        let border = self.opts.border;
92        let inner = self.inner(parent);
93        let cols = inner.cols;
94
95        // Don't print empty tables.
96        if self.is_empty() {
97            return lines;
98        }
99
100        if let Some(color) = border {
101            lines.push(
102                Line::default()
103                    .item(Paint::new("╭").fg(color))
104                    .item(Paint::new("─".repeat(cols)).fg(color))
105                    .item(Paint::new("╮").fg(color)),
106            );
107        }
108
109        for row in &self.rows {
110            let mut line = Line::default();
111
112            match row {
113                Row::Header(cells) | Row::Data(cells) => {
114                    if let Some(color) = border {
115                        line.push(Paint::new("│ ").fg(color));
116                    }
117                    for (i, cell) in cells.iter().enumerate() {
118                        let pad = if i == cells.len() - 1 {
119                            0
120                        } else {
121                            self.widths[i] + self.opts.spacing
122                        };
123                        line = line.extend(
124                            cell.pad(pad)
125                                .into()
126                                .style(Style::default().bg(cell.background())),
127                        );
128                    }
129                    Line::pad(&mut line, cols);
130                    Line::truncate(&mut line, cols, "…");
131
132                    if let Some(color) = border {
133                        line.push(Paint::new(" │").fg(color));
134                    }
135                    lines.push(line);
136                }
137                Row::Divider => {
138                    if let Some(color) = border {
139                        lines.push(
140                            Line::default()
141                                .item(Paint::new("├").fg(color))
142                                .item(Paint::new("─".repeat(cols)).fg(color))
143                                .item(Paint::new("┤").fg(color)),
144                        );
145                    } else {
146                        lines.push(Line::default());
147                    }
148                }
149            }
150        }
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        }
159        lines
160    }
161}
162
163impl<const W: usize, T: Cell> Table<W, T> {
164    pub fn new(opts: TableOptions) -> Self {
165        Self {
166            rows: Vec::new(),
167            widths: [0; W],
168            opts,
169        }
170    }
171
172    pub fn size(&self, parent: Constraint) -> Size {
173        self.outer(parent)
174    }
175
176    pub fn divider(&mut self) {
177        self.rows.push(Row::Divider);
178    }
179
180    pub fn push(&mut self, row: [T; W]) {
181        for (i, cell) in row.iter().enumerate() {
182            self.widths[i] = self.widths[i].max(cell.width());
183        }
184        self.rows.push(Row::Data(row));
185    }
186
187    pub fn header(&mut self, row: [T; W]) {
188        for (i, cell) in row.iter().enumerate() {
189            self.widths[i] = self.widths[i].max(cell.width());
190        }
191        self.rows.push(Row::Header(row));
192    }
193
194    pub fn extend(&mut self, rows: impl IntoIterator<Item = [T; W]>) {
195        for row in rows.into_iter() {
196            self.push(row);
197        }
198    }
199
200    pub fn is_empty(&self) -> bool {
201        !self.rows.iter().any(|r| matches!(r, Row::Data { .. }))
202    }
203
204    fn inner(&self, c: Constraint) -> Size {
205        let mut outer = self.outer(c);
206
207        if self.opts.border.is_some() {
208            outer.cols -= 2;
209            outer.rows -= 2;
210        }
211        outer
212    }
213
214    fn outer(&self, c: Constraint) -> Size {
215        let mut cols = self.widths.iter().sum::<usize>() + (W - 1) * self.opts.spacing;
216        let mut rows = self.rows.len();
217        let padding = 2;
218
219        // Account for outer borders.
220        if self.opts.border.is_some() {
221            cols += 2 + padding;
222            rows += 2;
223        }
224        Size::new(cols, rows).constrain(c)
225    }
226}
227
228#[cfg(test)]
229mod test {
230    use crate::Element;
231
232    use super::*;
233    use pretty_assertions::assert_eq;
234
235    #[test]
236    fn test_truncate() {
237        assert_eq!("🍍".truncate(1, "…"), String::from("…"));
238        assert_eq!("🍍".truncate(1, ""), String::from(""));
239        assert_eq!("🍍🍍".truncate(2, "…"), String::from("…"));
240        assert_eq!("🍍🍍".truncate(3, "…"), String::from("🍍…"));
241        assert_eq!("🍍".truncate(1, "🍎"), String::from(""));
242        assert_eq!("🍍".truncate(2, "🍎"), String::from("🍍"));
243        assert_eq!("🍍🍍".truncate(3, "🍎"), String::from("🍎"));
244        assert_eq!("🍍🍍🍍".truncate(4, "🍎"), String::from("🍍🍎"));
245        assert_eq!("hello".truncate(3, "…"), String::from("he…"));
246    }
247
248    #[test]
249    fn test_table() {
250        let mut t = Table::new(TableOptions::default());
251
252        t.push(["pineapple", "rosemary"]);
253        t.push(["apples", "pears"]);
254
255        #[rustfmt::skip]
256        assert_eq!(
257            t.display(Constraint::UNBOUNDED),
258            [
259                "pineapple rosemary\n",
260                "apples    pears   \n"
261            ].join("")
262        );
263    }
264
265    #[test]
266    fn test_table_border() {
267        let mut t = Table::new(TableOptions {
268            border: Some(Color::Unset),
269            spacing: 3,
270            ..TableOptions::default()
271        });
272
273        t.push(["Country", "Population", "Code"]);
274        t.divider();
275        t.push(["France", "60M", "FR"]);
276        t.push(["Switzerland", "7M", "CH"]);
277        t.push(["Germany", "80M", "DE"]);
278
279        let inner = t.inner(Constraint::UNBOUNDED);
280        assert_eq!(inner.cols, 33);
281        assert_eq!(inner.rows, 5);
282
283        let outer = t.outer(Constraint::UNBOUNDED);
284        assert_eq!(outer.cols, 35);
285        assert_eq!(outer.rows, 7);
286
287        assert_eq!(
288            t.display(Constraint::UNBOUNDED),
289            r#"
290╭─────────────────────────────────╮
291│ Country       Population   Code │
292├─────────────────────────────────┤
293│ France        60M          FR   │
294│ Switzerland   7M           CH   │
295│ Germany       80M          DE   │
296╰─────────────────────────────────╯
297"#
298            .trim_start()
299        );
300    }
301
302    #[test]
303    fn test_table_border_truncated() {
304        let mut t = Table::new(TableOptions {
305            border: Some(Color::Unset),
306            spacing: 3,
307            ..TableOptions::default()
308        });
309
310        t.push(["Code", "Name"]);
311        t.divider();
312        t.push(["FR", "France"]);
313        t.push(["CH", "Switzerland"]);
314        t.push(["DE", "Germany"]);
315
316        let constrain = Constraint::max(Size {
317            cols: 19,
318            rows: usize::MAX,
319        });
320        let outer = t.outer(constrain);
321        assert_eq!(outer.cols, 19);
322        assert_eq!(outer.rows, 7);
323
324        let inner = t.inner(constrain);
325        assert_eq!(inner.cols, 17);
326        assert_eq!(inner.rows, 5);
327
328        assert_eq!(
329            t.display(constrain),
330            r#"
331╭─────────────────╮
332│ Code   Name     │
333├─────────────────┤
334│ FR     France   │
335│ CH     Switzer… │
336│ DE     Germany  │
337╰─────────────────╯
338"#
339            .trim_start()
340        );
341    }
342
343    #[test]
344    fn test_table_border_maximized() {
345        let mut t = Table::new(TableOptions {
346            border: Some(Color::Unset),
347            spacing: 3,
348            ..TableOptions::default()
349        });
350
351        t.push(["Code", "Name"]);
352        t.divider();
353        t.push(["FR", "France"]);
354        t.push(["CH", "Switzerland"]);
355        t.push(["DE", "Germany"]);
356
357        let constrain = Constraint::new(
358            Size { cols: 26, rows: 0 },
359            Size {
360                cols: 26,
361                rows: usize::MAX,
362            },
363        );
364        let outer = t.outer(constrain);
365        assert_eq!(outer.cols, 26);
366        assert_eq!(outer.rows, 7);
367
368        let inner = t.inner(constrain);
369        assert_eq!(inner.cols, 24);
370        assert_eq!(inner.rows, 5);
371
372        assert_eq!(
373            t.display(constrain),
374            r#"
375╭────────────────────────╮
376│ Code   Name            │
377├────────────────────────┤
378│ FR     France          │
379│ CH     Switzerland     │
380│ DE     Germany         │
381╰────────────────────────╯
382"#
383            .trim_start()
384        );
385    }
386
387    #[test]
388    fn test_table_truncate() {
389        let mut t = Table::default();
390        let constrain = Constraint::new(
391            Size::MIN,
392            Size {
393                cols: 16,
394                rows: usize::MAX,
395            },
396        );
397
398        t.push(["pineapple", "rosemary"]);
399        t.push(["apples", "pears"]);
400
401        #[rustfmt::skip]
402        assert_eq!(
403            t.display(constrain),
404            [
405                "pineapple rosem…\n",
406                "apples    pears \n"
407            ].join("")
408        );
409    }
410
411    #[test]
412    fn test_table_unicode() {
413        let mut t = Table::new(TableOptions::default());
414
415        t.push(["🍍pineapple", "__rosemary", "__sage"]);
416        t.push(["__pears", "🍎apples", "🍌bananas"]);
417
418        #[rustfmt::skip]
419        assert_eq!(
420            t.display(Constraint::UNBOUNDED),
421            [
422                "🍍pineapple __rosemary __sage   \n",
423                "__pears     🍎apples   🍌bananas\n"
424            ].join("")
425        );
426    }
427
428    #[test]
429    fn test_table_unicode_truncate() {
430        let mut t = Table::new(TableOptions {
431            ..TableOptions::default()
432        });
433        let constrain = Constraint::max(Size {
434            cols: 16,
435            rows: usize::MAX,
436        });
437        t.push(["🍍pineapple", "__rosemary"]);
438        t.push(["__pears", "🍎apples"]);
439
440        #[rustfmt::skip]
441        assert_eq!(
442            t.display(constrain),
443            [
444                "🍍pineapple __r…\n",
445                "__pears     🍎a…\n"
446            ].join("")
447        );
448    }
449}