Skip to main content

fancy_table/
fancy.rs

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