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 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 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 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 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 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); 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 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 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 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}