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