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