1#![allow(clippy::type_complexity)]
2use std::borrow::Cow;
3
4use ansi_term::Style;
5use regex::Regex;
6
7lazy_static! {
8 pub static ref ANSI_REGEX: Regex = Regex::new("\x1b\\[[\\d;]*m").unwrap();
19}
20
21pub 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}