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