tiempo/
tabulate.rs

1#![allow(clippy::type_complexity)]
2use std::borrow::Cow;
3
4use ansi_term::Style;
5use regex::Regex;
6
7lazy_static! {
8    // https://en.wikipedia.org/wiki/ANSI_escape_code#DOS,_OS/2,_and_Windows
9    //
10    // For Control Sequence Introducer, or CSI, commands, the ESC [ is followed
11    // by any number (including none) of "parameter bytes" in the range
12    // 0x30–0x3F (ASCII 0–9:;<=>?), then by any number of "intermediate bytes"
13    // in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), then finally
14    // by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~)
15    //
16    // The lazy regex bellow doesn't cover all of that. It just works on ansi
17    // colors.
18    pub static ref ANSI_REGEX: Regex = Regex::new("\x1b\\[[\\d;]*m").unwrap();
19}
20
21/// An abstract way of getting the visual size of a string in a terminal
22pub trait VisualSize {
23    fn size(&self) -> usize;
24}
25
26impl VisualSize for &str {
27    fn size(&self) -> usize {
28        let s = ANSI_REGEX.replace_all(self, "");
29
30        s.chars().count()
31    }
32}
33
34impl VisualSize for String {
35    fn size(&self) -> usize {
36        self.as_str().size()
37    }
38}
39
40fn lpad(s: &str, len: usize) -> String {
41    let padding = " ".repeat(len.saturating_sub(s.size()));
42
43    padding + s
44}
45
46fn rpad(s: &str, len: usize) -> String {
47    let padding = " ".repeat(len.saturating_sub(s.size()));
48
49    s.to_string() + &padding
50}
51
52fn constrained_lines(text: &str, width: usize) -> Vec<Cow<'_, str>> {
53    textwrap::wrap(text, width)
54}
55
56#[derive(Copy, Clone)]
57pub enum Align {
58    Left,
59    Right,
60}
61
62use Align::*;
63
64#[derive(Clone)]
65pub struct Col {
66    min_width: usize,
67    max_width: Option<usize>,
68    align: Align,
69    conditonal_styles: Vec<(Style, fn(&str) -> bool)>,
70}
71
72impl Col {
73    pub fn new() -> Col {
74        Col {
75            min_width: 0,
76            align: Align::Left,
77            max_width: None,
78            conditonal_styles: Vec::new(),
79        }
80    }
81
82    pub fn min_width(self, size: usize) -> Col {
83        Col {
84            min_width: size,
85            ..self
86        }
87    }
88
89    pub fn and_alignment(self, align: Align) -> Col {
90        Col {
91            align,
92            ..self
93        }
94    }
95
96    pub fn max_width(self, size: usize) -> Col {
97        Col {
98            max_width: Some(size),
99            ..self
100        }
101    }
102
103    pub fn color_if(self, style: Style, f: fn(&str) -> bool) -> Col {
104        let mut conditonal_styles = self.conditonal_styles;
105
106        conditonal_styles.push((style, f));
107
108        Col {
109            conditonal_styles,
110            ..self
111        }
112    }
113}
114
115impl Default for Col {
116    fn default() -> Col {
117        Col::new()
118    }
119}
120
121enum DataOrSep {
122    Data(Vec<String>),
123    Sep(char),
124}
125
126pub struct Tabulate {
127    cols: Vec<Col>,
128    widths: Vec<usize>,
129    data: Vec<DataOrSep>,
130}
131
132impl Tabulate {
133    pub fn with_columns(cols: Vec<Col>) -> Tabulate {
134        Tabulate {
135            widths: cols.iter().map(|c| c.min_width).collect(),
136            cols,
137            data: Vec::new(),
138        }
139    }
140
141    pub fn feed<T: AsRef<str>>(&mut self, data: Vec<T>) {
142        let mut lines: Vec<Vec<String>> = Vec::new();
143
144        for (col, ((w, d), c)) in self.widths.iter_mut().zip(data.iter()).zip(self.cols.iter()).enumerate() {
145            for (r1, dl) in d.as_ref().split('\n').enumerate() {
146                for (r2, l) in constrained_lines(dl, c.max_width.unwrap_or(usize::MAX)).into_iter().enumerate() {
147                    let width = l.as_ref().size();
148
149                    if width > *w {
150                        *w = width;
151                    }
152
153                    if let Some(line) = lines.get_mut(r1 + r2) {
154                        if let Some(pos) = line.get_mut(col) {
155                            *pos = l.into();
156                        } else {
157                            line.push(l.into());
158                        }
159                    } else {
160                        lines.push({
161                            let mut prev: Vec<_> = if (r1 + r2) == 0 {
162                                data[..col].iter().map(|s| s.as_ref().to_string()).collect()
163                            } else {
164                                (0..col).map(|_| "".into()).collect()
165                            };
166
167                            prev.push(l.into());
168
169                            prev
170                        });
171                    }
172                }
173            }
174        }
175
176        for line in lines {
177            self.data.push(DataOrSep::Data(line));
178        }
179    }
180
181    pub fn separator(&mut self, c: char) {
182        self.data.push(DataOrSep::Sep(c));
183    }
184
185    pub fn print(self, color: bool) -> String {
186        let widths = self.widths;
187        let cols = self.cols;
188
189        self.data.into_iter().map(|row| match row {
190            DataOrSep::Sep(c) => {
191                if c == ' ' {
192                    "\n".into()
193                } else {
194                    c.to_string().repeat(widths.iter().sum::<usize>() + widths.len() -1) + "\n"
195                }
196            },
197            DataOrSep::Data(d) => {
198                d.into_iter().zip(widths.iter()).zip(cols.iter()).map(|((d, &w), c)| {
199                    let style = c.conditonal_styles.iter().find(|(_s, f)| {
200                        f(&d)
201                    }).map(|(s, _f)| s);
202
203                    let s = match c.align {
204                        Left => rpad(&d, w),
205                        Right => lpad(&d, w),
206                    };
207
208                    if let Some(style) = style {
209                        if color {
210                            style.paint(s).to_string()
211                        } else {
212                            s
213                        }
214                    } else {
215                        s
216                    }
217                }).collect::<Vec<_>>().join(" ").trim_end().to_string() + "\n"
218            },
219        }).collect::<Vec<_>>().join("")
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use pretty_assertions::assert_eq;
226    use ansi_term::Color::Fixed;
227
228    use super::*;
229
230    const LONG_NOTE: &str = "chatting with bob about upcoming task, district sharing of images, how the user settings currently works etc. Discussing the fingerprinting / cache busting issue with CKEDITOR, suggesting perhaps looking into forking the rubygem and seeing if we can work in our own changes, however hard that might be.";
231
232    #[test]
233    fn test_constrained_lines_long_text() {
234        assert_eq!(constrained_lines(LONG_NOTE, 46), vec![
235            "chatting with bob about upcoming task,",
236            "district sharing of images, how the user",
237            "settings currently works etc. Discussing the",
238            "fingerprinting / cache busting issue with",
239            "CKEDITOR, suggesting perhaps looking into",
240            "forking the rubygem and seeing if we can work",
241            "in our own changes, however hard that might",
242            "be.",
243        ]);
244    }
245
246    #[test]
247    fn test_constrained_lines_nowrap() {
248        assert_eq!(constrained_lines(LONG_NOTE, LONG_NOTE.len()), vec![
249            LONG_NOTE,
250        ]);
251    }
252
253    #[test]
254    fn test_text_output() {
255        let mut tabs = Tabulate::with_columns(vec![
256            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
257            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
258            Col::new().min_width("Duration".len()).and_alignment(Right),
259            Col::new().min_width("Notes".len()).and_alignment(Left),
260        ]);
261
262        tabs.feed(vec!["Day", "Start      End", "Duration", "Notes"]);
263        tabs.feed(vec!["Fri Oct 03, 2008", "12:00:00 - 14:00:00", "2:00:00", "entry 1"]);
264        tabs.feed(vec!["", "16:00:00 - 18:00:00",    "2:00:00", "entry 2"]);
265        tabs.feed(vec!["", "", "4:00:00", ""]);
266        tabs.feed(vec!["Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", "entry 3"]);
267        tabs.feed(vec!["", "18:00:00 - ", "2:00:00", "entry 4"]);
268        tabs.feed(vec!["", "", "4:00:00", ""]);
269        tabs.separator('-');
270        tabs.feed(vec!["Total", "", "8:00:00", ""]);
271
272        assert_eq!(&tabs.print(false), "\
273Day                Start      End        Duration Notes
274Fri Oct 03, 2008   12:00:00 - 14:00:00    2:00:00 entry 1
275                   16:00:00 - 18:00:00    2:00:00 entry 2
276                                          4:00:00
277Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 entry 3
278                   18:00:00 -             2:00:00 entry 4
279                                          4:00:00
280---------------------------------------------------------
281Total                                     8:00:00
282");
283    }
284
285    #[test]
286    fn test_text_output_long_duration() {
287        let mut tabs = Tabulate::with_columns(vec![
288            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
289            Col::new().min_width("12:00:00 - 14:00:00".len()).and_alignment(Left),
290            Col::new().min_width("Duration".len()).and_alignment(Right),
291            Col::new().min_width("Notes".len()).and_alignment(Left),
292        ]);
293
294        tabs.feed(vec!["Day", "Start      End", "Duration", "Notes"]);
295        tabs.feed(vec!["Wed Oct 01, 2008",   "12:00:00 - 14:00:00+2d", "50:00:00", "entry 1"]);
296        tabs.feed(vec!["", "", "50:00:00", ""]);
297        tabs.feed(vec!["Fri Oct 03, 2008",   "12:00:00 - 14:00:00",     "2:00:00", "entry 2"]);
298        tabs.feed(vec!["", "", "2:00:00", ""]);
299        tabs.separator('-');
300        tabs.feed(vec!["Total",                                     "", "52:00:00", ""]);
301
302        assert_eq!(&tabs.print(false), "\
303Day                Start      End         Duration Notes
304Wed Oct 01, 2008   12:00:00 - 14:00:00+2d 50:00:00 entry 1
305                                          50:00:00
306Fri Oct 03, 2008   12:00:00 - 14:00:00     2:00:00 entry 2
307                                           2:00:00
308----------------------------------------------------------
309Total                                     52:00:00
310");
311    }
312
313    #[test]
314    fn test_text_output_with_ids() {
315        let mut tabs = Tabulate::with_columns(vec![
316            Col::new().min_width(3).and_alignment(Right),
317            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
318            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
319            Col::new().min_width("Duration".len()).and_alignment(Right),
320            Col::new().min_width("Notes".len()).and_alignment(Left),
321        ]);
322
323        tabs.feed(vec!["ID", "Day", "Start      End", "Duration", "Notes"]);
324        tabs.feed(vec!["1", "Fri Oct 03, 2008",   "12:00:00 - 14:00:00",    "2:00:00", "entry 1"]);
325        tabs.feed(vec!["2", "", "16:00:00 - 18:00:00", "2:00:00", "entry 2"]);
326        tabs.feed(vec!["", "", "", "4:00:00", ""]);
327        tabs.feed(vec!["3", "Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", "entry 3"]);
328        tabs.feed(vec!["4", "", "18:00:00 -", "2:00:00", "entry 4"]);
329        tabs.feed(vec!["", "", "", "4:00:00", ""]);
330        tabs.separator('-');
331        tabs.feed(vec!["", "Total", "", "8:00:00"]);
332
333        assert_eq!(&tabs.print(false), " ID Day                Start      End        Duration Notes
334  1 Fri Oct 03, 2008   12:00:00 - 14:00:00    2:00:00 entry 1
335  2                    16:00:00 - 18:00:00    2:00:00 entry 2
336                                              4:00:00
337  3 Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 entry 3
338  4                    18:00:00 -             2:00:00 entry 4
339                                              4:00:00
340-------------------------------------------------------------
341    Total                                     8:00:00
342");
343    }
344
345    #[test]
346    fn test_text_output_long_note_with_ids() {
347        let mut tabs = Tabulate::with_columns(vec![
348            Col::new().min_width(2).and_alignment(Right),
349            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
350            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
351            Col::new().min_width("Duration".len()).and_alignment(Right),
352            Col::new().min_width("Notes".len()).max_width(44).and_alignment(Left),
353        ]);
354
355        tabs.feed(vec!["ID", "Day", "Start      End", "Duration", "Notes"]);
356        tabs.feed(vec!["60000", "Sun Oct 05, 2008",   "16:00:00 - 18:00:00",    "2:00:00", LONG_NOTE]);
357        tabs.feed(vec!["", "", "", "2:00:00", ""]);
358        tabs.separator('-');
359        tabs.feed(vec!["", "Total", "", "2:00:00"]);
360
361        assert_eq!(&tabs.print(false), "   ID Day                Start      End        Duration Notes
36260000 Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 chatting with bob about upcoming task,
363                                                        district sharing of images, how the user
364                                                        settings currently works etc. Discussing the
365                                                        fingerprinting / cache busting issue with
366                                                        CKEDITOR, suggesting perhaps looking into
367                                                        forking the rubygem and seeing if we can
368                                                        work in our own changes, however hard that
369                                                        might be.
370                                                2:00:00
371----------------------------------------------------------------------------------------------------
372      Total                                     2:00:00
373");
374    }
375
376    #[test]
377    fn test_text_output_note_with_line_breaks() {
378        let mut tabs = Tabulate::with_columns(vec![
379            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
380            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
381            Col::new().min_width("Duration".len()).and_alignment(Right),
382            Col::new().min_width("Notes".len()).and_alignment(Left),
383        ]);
384
385        tabs.feed(vec!["Day", "Start      End", "Duration", "Notes"]);
386        tabs.feed(vec!["Sun Oct 05, 2008",   "16:00:00 - 18:00:00",    "2:00:00", "first line\nand a second line"]);
387        tabs.feed(vec!["", "", "2:00:00", ""]);
388        tabs.separator('-');
389        tabs.feed(vec!["Total", "", "2:00:00", ""]);
390
391        assert_eq!(&tabs.print(false), "\
392Day                Start      End        Duration Notes
393Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 first line
394                                                  and a second line
395                                          2:00:00
396-------------------------------------------------------------------
397Total                                     2:00:00
398");
399    }
400
401    #[test]
402    fn note_with_accents() {
403        let mut tabs = Tabulate::with_columns(vec![
404            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
405            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
406            Col::new().min_width("Duration".len()).and_alignment(Right),
407            Col::new().min_width("Notes".len()).and_alignment(Left),
408        ]);
409
410        tabs.feed(vec!["Day", "Start      End", "Duration", "Notes"]);
411        tabs.feed(vec!["Sun Oct 05, 2008",   "16:00:00 - 18:00:00",    "2:00:00", "quiúbole"]);
412        tabs.feed(vec!["", "", "2:00:00", ""]);
413        tabs.separator('-');
414        tabs.feed(vec!["Total", "", "2:00:00", ""]);
415
416        assert_eq!(&tabs.print(false), "\
417Day                Start      End        Duration Notes
418Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 quiúbole
419                                          2:00:00
420----------------------------------------------------------
421Total                                     2:00:00
422");
423    }
424
425    #[test]
426    fn tabulate_a_blank_row() {
427        let mut tabs = Tabulate::with_columns(vec![
428            Col::new()
429        ]);
430
431        tabs.feed(vec!["Hola"]);
432        tabs.separator(' ');
433        tabs.feed(vec!["adiós"]);
434        tabs.separator('-');
435        tabs.feed(vec!["ta güeno"]);
436
437        assert_eq!(&tabs.print(false), "\
438Hola
439
440adiós
441--------
442ta güeno
443");
444    }
445
446    #[test]
447    fn add_a_color_condition() {
448        let mut tabs = Tabulate::with_columns(vec![
449            Col::new().color_if(Style::new().dimmed(), |val| {
450                val == "key"
451            }),
452            Col::new(),
453        ]);
454
455        tabs.feed(vec!["foo", "key"]);
456        tabs.feed(vec!["key", "foo"]);
457
458        assert_eq!(tabs.print(true), format!("\
459foo key
460{} foo
461", Style::new().dimmed().paint("key")));
462    }
463
464    #[test]
465    fn sizes_of_things() {
466        assert_eq!("🥦".size(), 1);
467        assert_eq!("á".size(), 1);
468        assert_eq!(Fixed(10).paint("hola").to_string().size(), 4);
469    }
470}