fancy_table/
fancy.rs

1use crate::{
2    charset::Charset,
3    padstr::{Pad, PadStr},
4    Align, ColSpec, FancyTable, FancyTableBuilder, FancyTableOpts, Layout, Overflow, Separator,
5    TitleAlign, TitleSpec,
6};
7
8const DEFAULT_COLUMN_WIDTH: usize = 10;
9
10impl Default for FancyTableOpts {
11    fn default() -> Self {
12        Self {
13            title_align: TitleAlign::LeftOffset(4),
14            charset: Charset::Modern,
15            headers_separator: Some(Separator::Double),
16            rows_separator: None,
17            max_lines: 3,
18        }
19    }
20}
21
22impl<'a, T: AsRef<str>> FancyTableBuilder<'a, T> {
23    fn new(opts: FancyTableOpts) -> Self {
24        Self {
25            headers: Vec::new(),
26            columns: Vec::new(),
27            padding: 1,
28            charset: opts.charset,
29            rows_separator: opts.rows_separator,
30            headers_separator: opts.headers_separator,
31            max_lines: opts.max_lines,
32            title: None,
33            title_align: opts.title_align,
34        }
35    }
36    fn add_column_spec(
37        mut self,
38        width: usize,
39        max_lines: usize,
40        layout: Layout,
41        align: Align,
42        overflow: Overflow,
43    ) -> Self {
44        self.columns.push(ColSpec {
45            width,
46            layout,
47            align,
48            overflow,
49            max_lines,
50        });
51        self
52    }
53
54    pub fn add_column(
55        mut self,
56        header: Option<T>,
57        layout: Layout,
58        align: Align,
59        overflow: Overflow,
60        max_lines: usize,
61    ) -> Self {
62        let len = match layout {
63            Layout::Fixed(f) => f,
64            _ => header
65                .as_ref()
66                .map(|h| h.as_ref().chars().count())
67                .unwrap_or(DEFAULT_COLUMN_WIDTH),
68        };
69        if let Some(header) = header {
70            self.headers.push(header);
71        }
72        self.add_column_spec(len, max_lines, layout, align, overflow)
73    }
74    pub fn add_column_named(self, header: T, layout: Layout) -> Self {
75        self.add_column_named_with_align(header, layout, Align::Left)
76    }
77    pub fn add_column_named_wrapping(self, header: T, layout: Layout) -> Self {
78        self.add_column_named_wrapping_with_align(header, layout, Align::Left)
79    }
80    pub fn add_column_named_with_align(mut self, header: T, layout: Layout, align: Align) -> Self {
81        let len = header.as_ref().len();
82        let max_lines = self.max_lines;
83
84        self.headers.push(header);
85        self.add_column_spec(len, max_lines, layout, align, Overflow::Truncate)
86    }
87    pub fn add_column_named_wrapping_with_align(
88        mut self,
89        header: T,
90        layout: Layout,
91        align: Align,
92    ) -> Self {
93        let len = header.as_ref().len();
94        let max_lines = self.max_lines;
95
96        self.headers.push(header);
97        self.add_column_spec(len, max_lines, layout, align, Overflow::Wrap)
98    }
99    pub fn add_title(mut self, title: &'a str) -> Self {
100        self.title = Some(title);
101        self
102    }
103    pub fn add_title_with_align(mut self, title: &'a str, align: TitleAlign) -> Self {
104        self.title_align = align;
105        self.add_title(title)
106    }
107    pub fn padding(mut self, padding: usize) -> Self {
108        self.padding = padding;
109        self
110    }
111    pub fn hseparator(mut self, separator: Option<Separator>) -> Self {
112        self.headers_separator = separator;
113        self
114    }
115    pub fn rseparator(mut self, separator: Option<Separator>) -> Self {
116        self.rows_separator = separator;
117        self
118    }
119
120    pub fn build(self, table_width: usize) -> FancyTable<'a, T> {
121        let title = self.title.map(|t| TitleSpec {
122            title: t,
123            align: self.title_align,
124        });
125        let mut table = FancyTable {
126            width: table_width,
127            chars: self.charset.get_chars(),
128            rows_separator: self.rows_separator,
129            headers_separator: self.headers_separator,
130            padding: self.padding,
131            headers: self.headers,
132            columns: self.columns,
133            title,
134        };
135        table.recalculate(table_width);
136        table
137    }
138}
139
140impl<'a, T: AsRef<str>> FancyTable<'a, T> {
141    pub fn create(opts: FancyTableOpts) -> FancyTableBuilder<'a, T> {
142        FancyTableBuilder::new(opts)
143    }
144
145    fn recalculate(&mut self, table_width: usize) {
146        let cols_count = self.columns.len();
147        let mut min_table_width = 0;
148
149        // calculate minimal table width with all paddings counted in
150        for (i, spec) in self.columns.iter_mut().enumerate() {
151            let column_width = match spec.layout {
152                Layout::Fixed(width) => width,
153                Layout::Slim | Layout::Expandable(_) => self
154                    .headers
155                    .get(i)
156                    .map(|h| h.as_ref().len() + (2 * self.padding))
157                    .unwrap_or(0),
158            };
159            spec.width = column_width;
160            min_table_width += spec.width;
161        }
162
163        min_table_width += cols_count + 1;
164
165        // adjust columns widths so, that they will all sum up to desired `table_width`
166        // by calculating remaining width and distributing it equally (as much as possible)
167        // among all expandable columns.
168        let mut remaining_width = table_width.saturating_sub(min_table_width);
169
170        if remaining_width > 0 {
171            let expandable_cols = self
172                .columns
173                .iter_mut()
174                .filter_map(|c| match c.layout {
175                    Layout::Expandable(max_width) => Some((c, max_width)),
176                    _ => None,
177                })
178                .collect::<Vec<_>>();
179
180            let mut expandable_count = expandable_cols.len();
181            for (ec, max_width) in expandable_cols {
182                let new_width = compensate(ec.width, max_width, remaining_width / expandable_count);
183                let compensation = new_width.saturating_sub(ec.width);
184
185                if new_width > ec.width {
186                    ec.width = new_width;
187                }
188                remaining_width -= compensation;
189                expandable_count -= 1;
190            }
191        }
192    }
193
194    fn generate_empty_string(&self, col_idx: usize, padding: usize) -> String {
195        if let Some(col) = self.columns.get(col_idx) {
196            let width = col.width.saturating_sub(2 * padding);
197            let mut result = String::with_capacity(width);
198            result.push_str(&" ".repeat(width));
199            return result;
200        }
201        String::default()
202    }
203
204    fn separator_chars(&self, separator: &Option<Separator>) -> (char, char, char, char) {
205        let ch = &self.chars;
206        match separator {
207            Some(Separator::Single) => (ch.ew, ch.news, ch.nes, ch.nws),
208            Some(Separator::Double) => (ch.dew, ch.dnews, ch.dnes, ch.dnws),
209            Some(Separator::Custom(c)) => (*c, ch.news, ch.nes, ch.nws),
210            None => ('-', '|', '|', '|'),
211        }
212    }
213
214    fn render_row(&self, row: &'a [T]) {
215        let mut padded = row
216            .iter()
217            .enumerate()
218            .map(|(i, s)| {
219                let col = self.columns.get(i).unwrap();
220                let pad = match col.align {
221                    Align::Left => Pad::Right,
222                    Align::Right => Pad::Left,
223                    Align::Center => Pad::Center,
224                };
225                match col.overflow {
226                    Overflow::Truncate => PadStr::truncating(s.as_ref()),
227                    Overflow::Wrap => PadStr::wrapping(s.as_ref()),
228                }
229                .paddify(
230                    col.width.saturating_sub(2 * self.padding),
231                    col.max_lines,
232                    pad,
233                )
234            })
235            .collect::<Vec<_>>();
236
237        let ns = self.chars.ns;
238        let len = padded.len();
239        let max_lines = padded.iter().map(|s| s.len()).max().unwrap_or(0);
240        let str_padding = self.padding;
241        let edg_padding = self.padding + 1;
242
243        for _ in 0..max_lines {
244            print!("{:edg_padding$}", ns);
245            for (i, vs) in padded.iter_mut().enumerate() {
246                let s = vs
247                    .pop_front()
248                    .unwrap_or_else(|| self.generate_empty_string(i, str_padding));
249                print!("{s}");
250                if i < len - 1 {
251                    print!("{:>str_padding$}{ns}{:>str_padding$}", "", "");
252                }
253            }
254            println!("{:>edg_padding$}", ns);
255        }
256    }
257
258    pub fn render<R: AsRef<[T]>>(&self, rows: Vec<R>) {
259        let ch = &self.chars;
260        let cols_count = self.columns.len();
261        let rows_count = rows.len();
262        let rsep_chars = self.separator_chars(&self.rows_separator);
263        let hsep_chars = self.separator_chars(&self.headers_separator);
264        let title_width = self
265            .title
266            .as_ref()
267            .map(|ts| ts.title.len() + 4)
268            .unwrap_or(0);
269
270        let mut acc = 1;
271        let mut border_top = vec![ch.ew; self.width];
272        let mut border_btm = vec![ch.ew; self.width];
273        let mut hseparator = vec![hsep_chars.0; self.width];
274        let mut rseparator = vec![rsep_chars.0; self.width];
275
276        border_top[0] = ch.se;
277        border_btm[0] = ch.ne;
278        border_top[self.width - 1] = ch.sw;
279        border_btm[self.width - 1] = ch.nw;
280
281        hseparator[0] = hsep_chars.2;
282        rseparator[0] = rsep_chars.2;
283        hseparator[self.width - 1] = hsep_chars.3;
284        rseparator[self.width - 1] = rsep_chars.3;
285
286        // prepare top and bottom lines.
287        for (i, spec) in self.columns.iter().enumerate() {
288            if i < cols_count - 1 {
289                acc += spec.width + 1;
290                border_top[acc - 1] = ch.ews;
291                border_btm[acc - 1] = ch.new;
292                hseparator[acc - 1] = hsep_chars.1;
293                rseparator[acc - 1] = rsep_chars.1;
294            }
295        }
296
297        // draw a title
298        if title_width > 0 && title_width < self.width - 4 {
299            let spec = self.title.as_ref().unwrap();
300            let start = match spec.align {
301                TitleAlign::LeftOffset(lo) => lo + 1,
302                TitleAlign::RightOffset(ro) => self.width - ro - title_width - 1,
303            };
304            let end = start + title_width;
305            let tch = ch.title;
306            border_top.splice(start..end, format!("{tch} {} {tch}", spec.title).chars());
307        }
308
309        let top = border_top.iter().collect::<String>();
310        let btm = border_btm.iter().collect::<String>();
311        let h_sep = hseparator.iter().collect::<String>();
312        let r_sep = rseparator.iter().collect::<String>();
313
314        println!("{top}");
315        if !self.headers.is_empty() {
316            self.render_row(self.headers.as_slice());
317            if self.headers_separator.is_some() {
318                println!("{h_sep}");
319            }
320        }
321        for (i, r) in rows.iter().enumerate() {
322            self.render_row(r.as_ref());
323            if i < rows_count - 1 && self.rows_separator.is_some() {
324                println!("{r_sep}");
325            }
326        }
327        println!("{btm}");
328    }
329}
330
331fn compensate(width: usize, max_width: usize, compensation: usize) -> usize {
332    let compensated = width + compensation;
333    if compensated > max_width {
334        max_width
335    } else {
336        compensated
337    }
338}
339
340#[cfg(test)]
341mod test {
342    use super::*;
343
344    #[test]
345    fn basic_constraints() {
346        let table = FancyTable::create(FancyTableOpts::default())
347            .add_column_named("ID", Layout::Fixed(8))
348            .add_column_named("NAME", Layout::Fixed(4))
349            .add_column_named("ROLE", Layout::Fixed(10))
350            .add_column_named("PERMISSION", Layout::Expandable(30))
351            .add_column_named("DESCRIPTION", Layout::Expandable(150))
352            .add_title("props")
353            .padding(0)
354            .build(80);
355
356        assert_eq!(table.columns.first().unwrap().width, 8);
357        assert_eq!(table.columns.get(1).unwrap().width, 4);
358        assert_eq!(table.columns.get(2).unwrap().width, 10);
359        assert_eq!(table.columns.get(3).unwrap().width, 25);
360        assert_eq!(
361            table.columns.get(4).unwrap().width,
362            80 - 6 - 8 - 4 - 10 - 25
363        );
364    }
365
366    #[test]
367    fn slim_table() {
368        let table = FancyTable::create(FancyTableOpts::default())
369            .add_column_named("ID", Layout::Slim)
370            .add_column_named("NAME", Layout::Slim)
371            .add_column_named("ROLE", Layout::Fixed(10))
372            .add_column_named("PERMISSION", Layout::Expandable(30))
373            .add_column_named("DESCRIPTION", Layout::Expandable(50))
374            .padding(0)
375            .build(0);
376
377        assert_eq!(table.columns.first().unwrap().width, 2);
378        assert_eq!(table.columns.get(1).unwrap().width, 4);
379        assert_eq!(table.columns.get(2).unwrap().width, 10);
380        assert_eq!(table.columns.get(3).unwrap().width, 10);
381        assert_eq!(table.columns.get(4).unwrap().width, 11);
382    }
383}