#![allow(clippy::type_complexity)] use std::borrow::Cow; use ansi_term::Style; use regex::Regex; lazy_static! { // https://en.wikipedia.org/wiki/ANSI_escape_code#DOS,_OS/2,_and_Windows // // For Control Sequence Introducer, or CSI, commands, the ESC [ is followed // by any number (including none) of "parameter bytes" in the range // 0x30–0x3F (ASCII 0–9:;<=>?), then by any number of "intermediate bytes" // in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), then finally // by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~) // // The lazy regex bellow doesn't cover all of that. It just works on ansi // colors. pub static ref ANSI_REGEX: Regex = Regex::new("\x1b\\[[\\d;]*m").unwrap(); } /// An abstract way of getting the visual size of a string in a terminal pub trait VisualSize { fn size(&self) -> usize; } impl VisualSize for &str { fn size(&self) -> usize { let s = ANSI_REGEX.replace_all(self, ""); s.chars().count() } } impl VisualSize for String { fn size(&self) -> usize { self.as_str().size() } } fn lpad(s: &str, len: usize) -> String { let padding = " ".repeat(len.saturating_sub(s.size())); padding + s } fn rpad(s: &str, len: usize) -> String { let padding = " ".repeat(len.saturating_sub(s.size())); s.to_string() + &padding } fn constrained_lines(text: &str, width: usize) -> Vec> { textwrap::wrap(text, width) } #[derive(Copy, Clone)] pub enum Align { Left, Right, } use Align::*; #[derive(Clone)] pub struct Col { min_width: usize, max_width: Option, align: Align, conditonal_styles: Vec<(Style, fn(&str) -> bool)>, } impl Col { pub fn new() -> Col { Col { min_width: 0, align: Align::Left, max_width: None, conditonal_styles: Vec::new(), } } pub fn min_width(self, size: usize) -> Col { Col { min_width: size, ..self } } pub fn and_alignment(self, align: Align) -> Col { Col { align, ..self } } pub fn max_width(self, size: usize) -> Col { Col { max_width: Some(size), ..self } } pub fn color_if(self, style: Style, f: fn(&str) -> bool) -> Col { let mut conditonal_styles = self.conditonal_styles; conditonal_styles.push((style, f)); Col { conditonal_styles, ..self } } } impl Default for Col { fn default() -> Col { Col::new() } } enum DataOrSep { Data(Vec), Sep(char), } pub struct Tabulate { cols: Vec, widths: Vec, data: Vec, } impl Tabulate { pub fn with_columns(cols: Vec) -> Tabulate { Tabulate { widths: cols.iter().map(|c| c.min_width).collect(), cols, data: Vec::new(), } } pub fn feed>(&mut self, data: Vec) { let mut lines: Vec> = Vec::new(); for (col, ((w, d), c)) in self.widths.iter_mut().zip(data.iter()).zip(self.cols.iter()).enumerate() { for (r1, dl) in d.as_ref().split('\n').enumerate() { for (r2, l) in constrained_lines(dl, c.max_width.unwrap_or(usize::MAX)).into_iter().enumerate() { let width = l.as_ref().size(); if width > *w { *w = width; } if let Some(line) = lines.get_mut(r1 + r2) { if let Some(pos) = line.get_mut(col) { *pos = l.into(); } else { line.push(l.into()); } } else { lines.push({ let mut prev: Vec<_> = if (r1 + r2) == 0 { data[..col].iter().map(|s| s.as_ref().to_string()).collect() } else { (0..col).map(|_| "".into()).collect() }; prev.push(l.into()); prev }); } } } } for line in lines { self.data.push(DataOrSep::Data(line)); } } pub fn separator(&mut self, c: char) { self.data.push(DataOrSep::Sep(c)); } pub fn print(self, color: bool) -> String { let widths = self.widths; let cols = self.cols; self.data.into_iter().map(|row| match row { DataOrSep::Sep(c) => { if c == ' ' { "\n".into() } else { c.to_string().repeat(widths.iter().sum::() + widths.len() -1) + "\n" } }, DataOrSep::Data(d) => { d.into_iter().zip(widths.iter()).zip(cols.iter()).map(|((d, &w), c)| { let style = c.conditonal_styles.iter().find(|(_s, f)| { f(&d) }).map(|(s, _f)| s); let s = match c.align { Left => rpad(&d, w), Right => lpad(&d, w), }; if let Some(style) = style { if color { style.paint(s).to_string() } else { s } } else { s } }).collect::>().join(" ").trim_end().to_string() + "\n" }, }).collect::>().join("") } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use ansi_term::Color::Fixed; use super::*; 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."; #[test] fn test_constrained_lines_long_text() { assert_eq!(constrained_lines(LONG_NOTE, 46), vec![ "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.", ]); } #[test] fn test_constrained_lines_nowrap() { assert_eq!(constrained_lines(LONG_NOTE, LONG_NOTE.len()), vec![ LONG_NOTE, ]); } #[test] fn test_text_output() { let mut tabs = Tabulate::with_columns(vec![ Col::new().min_width("Fri Oct 03, 2008 ".len()).and_alignment(Left), Col::new().min_width("12:00:00 - 14:00:00 ".len()).and_alignment(Left), Col::new().min_width("Duration".len()).and_alignment(Right), Col::new().min_width("Notes".len()).and_alignment(Left), ]); tabs.feed(vec!["Day", "Start End", "Duration", "Notes"]); tabs.feed(vec!["Fri Oct 03, 2008", "12:00:00 - 14:00:00", "2:00:00", "entry 1"]); tabs.feed(vec!["", "16:00:00 - 18:00:00", "2:00:00", "entry 2"]); tabs.feed(vec!["", "", "4:00:00", ""]); tabs.feed(vec!["Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", "entry 3"]); tabs.feed(vec!["", "18:00:00 - ", "2:00:00", "entry 4"]); tabs.feed(vec!["", "", "4:00:00", ""]); tabs.separator('-'); tabs.feed(vec!["Total", "", "8:00:00", ""]); assert_eq!(&tabs.print(false), "\ Day Start End Duration Notes Fri Oct 03, 2008 12:00:00 - 14:00:00 2:00:00 entry 1 16:00:00 - 18:00:00 2:00:00 entry 2 4:00:00 Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 entry 3 18:00:00 - 2:00:00 entry 4 4:00:00 --------------------------------------------------------- Total 8:00:00 "); } #[test] fn test_text_output_long_duration() { let mut tabs = Tabulate::with_columns(vec![ Col::new().min_width("Fri Oct 03, 2008 ".len()).and_alignment(Left), Col::new().min_width("12:00:00 - 14:00:00".len()).and_alignment(Left), Col::new().min_width("Duration".len()).and_alignment(Right), Col::new().min_width("Notes".len()).and_alignment(Left), ]); tabs.feed(vec!["Day", "Start End", "Duration", "Notes"]); tabs.feed(vec!["Wed Oct 01, 2008", "12:00:00 - 14:00:00+2d", "50:00:00", "entry 1"]); tabs.feed(vec!["", "", "50:00:00", ""]); tabs.feed(vec!["Fri Oct 03, 2008", "12:00:00 - 14:00:00", "2:00:00", "entry 2"]); tabs.feed(vec!["", "", "2:00:00", ""]); tabs.separator('-'); tabs.feed(vec!["Total", "", "52:00:00", ""]); assert_eq!(&tabs.print(false), "\ Day Start End Duration Notes Wed Oct 01, 2008 12:00:00 - 14:00:00+2d 50:00:00 entry 1 50:00:00 Fri Oct 03, 2008 12:00:00 - 14:00:00 2:00:00 entry 2 2:00:00 ---------------------------------------------------------- Total 52:00:00 "); } #[test] fn test_text_output_with_ids() { let mut tabs = Tabulate::with_columns(vec![ Col::new().min_width(3).and_alignment(Right), Col::new().min_width("Fri Oct 03, 2008 ".len()).and_alignment(Left), Col::new().min_width("12:00:00 - 14:00:00 ".len()).and_alignment(Left), Col::new().min_width("Duration".len()).and_alignment(Right), Col::new().min_width("Notes".len()).and_alignment(Left), ]); tabs.feed(vec!["ID", "Day", "Start End", "Duration", "Notes"]); tabs.feed(vec!["1", "Fri Oct 03, 2008", "12:00:00 - 14:00:00", "2:00:00", "entry 1"]); tabs.feed(vec!["2", "", "16:00:00 - 18:00:00", "2:00:00", "entry 2"]); tabs.feed(vec!["", "", "", "4:00:00", ""]); tabs.feed(vec!["3", "Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", "entry 3"]); tabs.feed(vec!["4", "", "18:00:00 -", "2:00:00", "entry 4"]); tabs.feed(vec!["", "", "", "4:00:00", ""]); tabs.separator('-'); tabs.feed(vec!["", "Total", "", "8:00:00"]); assert_eq!(&tabs.print(false), " ID Day Start End Duration Notes 1 Fri Oct 03, 2008 12:00:00 - 14:00:00 2:00:00 entry 1 2 16:00:00 - 18:00:00 2:00:00 entry 2 4:00:00 3 Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 entry 3 4 18:00:00 - 2:00:00 entry 4 4:00:00 ------------------------------------------------------------- Total 8:00:00 "); } #[test] fn test_text_output_long_note_with_ids() { let mut tabs = Tabulate::with_columns(vec![ Col::new().min_width(2).and_alignment(Right), Col::new().min_width("Fri Oct 03, 2008 ".len()).and_alignment(Left), Col::new().min_width("12:00:00 - 14:00:00 ".len()).and_alignment(Left), Col::new().min_width("Duration".len()).and_alignment(Right), Col::new().min_width("Notes".len()).max_width(44).and_alignment(Left), ]); tabs.feed(vec!["ID", "Day", "Start End", "Duration", "Notes"]); tabs.feed(vec!["60000", "Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", LONG_NOTE]); tabs.feed(vec!["", "", "", "2:00:00", ""]); tabs.separator('-'); tabs.feed(vec!["", "Total", "", "2:00:00"]); assert_eq!(&tabs.print(false), " ID Day Start End Duration Notes 60000 Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 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. 2:00:00 ---------------------------------------------------------------------------------------------------- Total 2:00:00 "); } #[test] fn test_text_output_note_with_line_breaks() { let mut tabs = Tabulate::with_columns(vec![ Col::new().min_width("Fri Oct 03, 2008 ".len()).and_alignment(Left), Col::new().min_width("12:00:00 - 14:00:00 ".len()).and_alignment(Left), Col::new().min_width("Duration".len()).and_alignment(Right), Col::new().min_width("Notes".len()).and_alignment(Left), ]); tabs.feed(vec!["Day", "Start End", "Duration", "Notes"]); tabs.feed(vec!["Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", "first line\nand a second line"]); tabs.feed(vec!["", "", "2:00:00", ""]); tabs.separator('-'); tabs.feed(vec!["Total", "", "2:00:00", ""]); assert_eq!(&tabs.print(false), "\ Day Start End Duration Notes Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 first line and a second line 2:00:00 ------------------------------------------------------------------- Total 2:00:00 "); } #[test] fn note_with_accents() { let mut tabs = Tabulate::with_columns(vec![ Col::new().min_width("Fri Oct 03, 2008 ".len()).and_alignment(Left), Col::new().min_width("12:00:00 - 14:00:00 ".len()).and_alignment(Left), Col::new().min_width("Duration".len()).and_alignment(Right), Col::new().min_width("Notes".len()).and_alignment(Left), ]); tabs.feed(vec!["Day", "Start End", "Duration", "Notes"]); tabs.feed(vec!["Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", "quiúbole"]); tabs.feed(vec!["", "", "2:00:00", ""]); tabs.separator('-'); tabs.feed(vec!["Total", "", "2:00:00", ""]); assert_eq!(&tabs.print(false), "\ Day Start End Duration Notes Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 quiúbole 2:00:00 ---------------------------------------------------------- Total 2:00:00 "); } #[test] fn tabulate_a_blank_row() { let mut tabs = Tabulate::with_columns(vec![ Col::new() ]); tabs.feed(vec!["Hola"]); tabs.separator(' '); tabs.feed(vec!["adiós"]); tabs.separator('-'); tabs.feed(vec!["ta güeno"]); assert_eq!(&tabs.print(false), "\ Hola adiós -------- ta güeno "); } #[test] fn add_a_color_condition() { let mut tabs = Tabulate::with_columns(vec![ Col::new().color_if(Style::new().dimmed(), |val| { val == "key" }), Col::new(), ]); tabs.feed(vec!["foo", "key"]); tabs.feed(vec!["key", "foo"]); assert_eq!(tabs.print(true), format!("\ foo key {} foo ", Style::new().dimmed().paint("key"))); } #[test] fn sizes_of_things() { assert_eq!("🥦".size(), 1); assert_eq!("á".size(), 1); assert_eq!(Fixed(10).paint("hola").to_string().size(), 4); } }