Skip to main content

fancy_table/
fancy.rs

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