modcli/output/
table.rs

1use console::measure_text_width;
2use crossterm::style::{Color, Stylize};
3use terminal_size::{terminal_size, Width};
4use unicode_segmentation::UnicodeSegmentation;
5
6#[derive(Clone, Copy)]
7pub enum Align {
8    Left,
9    Center,
10    Right,
11}
12
13#[cfg(feature = "table-presets")]
14/// Preset sugar: heavy borders, cyan header, separators on by default.
15pub fn render_table_preset_heavy_cyan_separators(
16    headers: &[&str],
17    rows: &[Vec<&str>],
18    mode: TableMode,
19    alignments: Option<&[Align]>,
20    trunc_modes: Option<&[TruncateMode]>,
21    row_separators: bool,
22) -> String {
23    render_table_with_opts_styled(
24        headers,
25        rows,
26        mode,
27        TableStyle::Heavy,
28        alignments,
29        trunc_modes,
30        true,
31        row_separators,
32        Some(crate::output::CYAN),
33        Some(crate::output::DARK_BLUE),
34    )
35}
36
37#[cfg(feature = "table-presets")]
38/// Preset sugar: minimal ASCII borders, magenta header, light grey zebra rows.
39pub fn render_table_preset_minimal_magenta_grey_zebra(
40    headers: &[&str],
41    rows: &[Vec<&str>],
42    mode: TableMode,
43    alignments: Option<&[Align]>,
44    trunc_modes: Option<&[TruncateMode]>,
45    row_separators: bool,
46) -> String {
47    render_table_with_opts_styled(
48        headers,
49        rows,
50        mode,
51        TableStyle::Ascii,
52        alignments,
53        trunc_modes,
54        true,
55        row_separators,
56        Some(crate::output::MAGENTA),
57        Some(crate::output::LIGHT_GREY),
58    )
59}
60
61/// Write Markdown table to a file path. Returns Result for error handling.
62pub fn write_table_markdown(
63    path: &str,
64    headers: &[&str],
65    rows: &[Vec<&str>],
66) -> std::io::Result<()> {
67    std::fs::write(path, render_table_markdown(headers, rows))
68}
69
70/// Write CSV table to a file path. Returns Result for error handling.
71pub fn write_table_csv(path: &str, headers: &[&str], rows: &[Vec<&str>]) -> std::io::Result<()> {
72    std::fs::write(path, render_table_csv(headers, rows))
73}
74
75/// Styled variant: allow optional header foreground color and zebra row background color.
76#[allow(clippy::too_many_arguments)]
77pub fn render_table_with_opts_styled(
78    headers: &[&str],
79    rows: &[Vec<&str>],
80    mode: TableMode,
81    style: TableStyle,
82    alignments: Option<&[Align]>,
83    trunc_modes: Option<&[TruncateMode]>,
84    zebra: bool,
85    row_separators: bool,
86    header_fg: Option<Color>,
87    zebra_bg: Option<Color>,
88) -> String {
89    let term_width = terminal_size()
90        .map(|(Width(w), _)| w as usize)
91        .unwrap_or(80);
92    let col_count = headers.len().max(1);
93    let padding: usize = 1;
94    let total_padding = (col_count - 1) * padding;
95
96    let col_width = match mode {
97        TableMode::Fixed(width) => width,
98        TableMode::Full => {
99            let border_space = col_count + 1;
100            let usable = term_width.saturating_sub(border_space);
101            usable / col_count
102        }
103        TableMode::Flex => {
104            let content_max = headers
105                .iter()
106                .map(|h| measure_text_width(h))
107                .chain(
108                    rows.iter()
109                        .flat_map(|r| r.iter().map(|c| measure_text_width(c))),
110                )
111                .max()
112                .unwrap_or(10);
113            content_max.min((term_width.saturating_sub(total_padding)) / col_count)
114        }
115    };
116
117    let border = match style {
118        TableStyle::Ascii => BorderSet::ascii(),
119        TableStyle::Rounded => BorderSet::rounded(),
120        TableStyle::Heavy => BorderSet::heavy(),
121    };
122
123    let mut out = String::with_capacity(128);
124
125    // Top Border
126    out.push(border.top_left);
127    for i in 0..col_count {
128        out.push_str(&border.horizontal.to_string().repeat(col_width));
129        if i < col_count - 1 {
130            out.push(border.top_cross);
131        }
132    }
133    out.push(border.top_right);
134    out.push('\n');
135
136    // Header Row (with optional color)
137    out.push(border.vertical);
138    for h in headers.iter() {
139        let a = pick_align(0, alignments);
140        let t = pick_trunc(0, trunc_modes);
141        let mut cell = pad_cell_with(h, col_width, a, t);
142        if let Some(color) = header_fg {
143            cell = cell.with(color).bold().to_string();
144        }
145        out.push_str(&cell);
146        out.push(border.vertical);
147    }
148    out.push('\n');
149
150    // Mid Border
151    out.push(border.mid_left);
152    for i in 0..col_count {
153        out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
154        if i < col_count - 1 {
155            out.push(border.mid_cross);
156        }
157    }
158    out.push(border.mid_right);
159    out.push('\n');
160
161    // Body Rows (optional zebra bg)
162    for (ri, row) in rows.iter().enumerate() {
163        out.push(border.vertical);
164        for (ci, cell) in row.iter().enumerate() {
165            let a = pick_align(ci, alignments);
166            let t = pick_trunc(ci, trunc_modes);
167            let base = pad_cell_with(cell, col_width, a, t);
168            let styled = if zebra && (ri % 2 == 1) {
169                if let Some(bg) = zebra_bg {
170                    base.on(bg).to_string()
171                } else {
172                    base
173                }
174            } else {
175                base
176            };
177            out.push_str(&styled);
178            out.push(border.vertical);
179        }
180        out.push('\n');
181
182        if row_separators && ri < rows.len() - 1 {
183            out.push(border.mid_left);
184            for i in 0..col_count {
185                out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
186                if i < col_count - 1 {
187                    out.push(border.mid_cross);
188                }
189            }
190            out.push(border.mid_right);
191            out.push('\n');
192        }
193    }
194
195    // Bottom Border
196    out.push(border.bottom_left);
197    for i in 0..col_count {
198        out.push_str(&border.horizontal.to_string().repeat(col_width));
199        if i < col_count - 1 {
200            out.push(border.bottom_cross);
201        }
202    }
203    out.push(border.bottom_right);
204    out.push('\n');
205
206    out
207}
208
209// --- Export adapters ---
210
211/// Render as GitHub-flavored Markdown table.
212pub fn render_table_markdown(headers: &[&str], rows: &[Vec<&str>]) -> String {
213    let mut out = String::new();
214    // header
215    out.push('|');
216    for h in headers {
217        out.push(' ');
218        out.push_str(&escape_md(h));
219        out.push(' ');
220        out.push('|');
221    }
222    out.push('\n');
223    // separator
224    out.push('|');
225    for _ in headers {
226        out.push_str(" --- |");
227    }
228    out.push('\n');
229    // rows
230    for row in rows {
231        out.push('|');
232        for cell in row {
233            out.push(' ');
234            out.push_str(&escape_md(cell));
235            out.push(' ');
236            out.push('|');
237        }
238        out.push('\n');
239    }
240    out
241}
242
243/// Render as CSV. Minimal escaping for quotes and commas/newlines.
244pub fn render_table_csv(headers: &[&str], rows: &[Vec<&str>]) -> String {
245    let mut out = String::new();
246    out.push_str(&join_csv(headers.iter().copied()));
247    out.push('\n');
248    for row in rows {
249        out.push_str(&join_csv(row.iter().copied()));
250        out.push('\n');
251    }
252    out
253}
254
255/// Render as JSON array of objects mapping header->value. Not streaming, small tables only.
256pub fn render_table_json(headers: &[&str], rows: &[Vec<&str>]) -> String {
257    use std::fmt::Write as _;
258    let mut out = String::from("[");
259    for (ri, row) in rows.iter().enumerate() {
260        if ri > 0 {
261            out.push(',');
262        }
263        out.push('{');
264        for (ci, h) in headers.iter().enumerate() {
265            if ci > 0 {
266                out.push(',');
267            }
268            let _ = write!(
269                out,
270                "\"{}\":{}",
271                escape_json(h),
272                json_string(row.get(ci).copied().unwrap_or(""))
273            );
274        }
275        out.push('}');
276    }
277    out.push(']');
278    out
279}
280
281fn escape_md(s: &str) -> String {
282    s.replace('|', "\\|")
283}
284
285fn join_csv<'a, I: IntoIterator<Item = &'a str>>(iter: I) -> String {
286    let mut first = true;
287    let mut s = String::new();
288    for field in iter {
289        if !first {
290            s.push(',');
291        } else {
292            first = false;
293        }
294        s.push_str(&csv_field(field));
295    }
296    s
297}
298
299fn csv_field(s: &str) -> String {
300    let need_quotes = s.contains(',') || s.contains('"') || s.contains('\n');
301    if need_quotes {
302        let escaped = s.replace('"', "\"\"");
303        format!("\"{escaped}\"")
304    } else {
305        s.to_string()
306    }
307}
308
309fn escape_json(s: &str) -> String {
310    s.replace('"', "\\\"")
311}
312fn json_string(s: &str) -> String {
313    format!("\"{}\"", escape_json(s))
314}
315
316#[derive(Clone, Copy)]
317pub enum TruncateMode {
318    End,
319    Middle,
320    Start,
321}
322
323pub enum TableMode {
324    Flex,
325    Fixed(usize),
326    Full,
327}
328
329pub enum TableStyle {
330    Ascii,
331    Rounded,
332    Heavy,
333}
334
335#[cfg(feature = "table-presets")]
336impl TableStyle {
337    #[inline]
338    pub fn ascii_preset() -> Self {
339        TableStyle::Ascii
340    }
341    #[inline]
342    pub fn rounded_preset() -> Self {
343        TableStyle::Rounded
344    }
345    #[inline]
346    pub fn heavy_preset() -> Self {
347        TableStyle::Heavy
348    }
349}
350
351pub fn render_table(
352    headers: &[&str],
353    rows: &[Vec<&str>],
354    mode: TableMode,
355    style: TableStyle,
356) -> String {
357    render_table_with(headers, rows, mode, style, None, None)
358}
359
360/// Advanced renderer allowing per-column alignment and truncation modes.
361pub fn render_table_with(
362    headers: &[&str],
363    rows: &[Vec<&str>],
364    mode: TableMode,
365    style: TableStyle,
366    alignments: Option<&[Align]>,
367    trunc_modes: Option<&[TruncateMode]>,
368) -> String {
369    render_table_with_opts(
370        headers,
371        rows,
372        mode,
373        style,
374        alignments,
375        trunc_modes,
376        false,
377        false,
378    )
379}
380
381/// Advanced renderer with options: per-column alignment/truncation, zebra stripes, and row separators.
382#[allow(clippy::too_many_arguments)]
383pub fn render_table_with_opts(
384    headers: &[&str],
385    rows: &[Vec<&str>],
386    mode: TableMode,
387    style: TableStyle,
388    alignments: Option<&[Align]>,
389    trunc_modes: Option<&[TruncateMode]>,
390    zebra: bool,
391    row_separators: bool,
392) -> String {
393    let term_width = terminal_size()
394        .map(|(Width(w), _)| w as usize)
395        .unwrap_or(80);
396    let col_count = headers.len().max(1);
397    let padding: usize = 1;
398    let total_padding = (col_count - 1) * padding;
399
400    let col_width = match mode {
401        TableMode::Fixed(width) => width,
402        TableMode::Full => {
403            let border_space = col_count + 1; // ┏┃┃┃┓ = 4 columns + 2 sides = 5 chars
404            let usable = term_width.saturating_sub(border_space);
405            usable / col_count
406        }
407        TableMode::Flex => {
408            let content_max = headers
409                .iter()
410                .map(|h| measure_text_width(h))
411                .chain(
412                    rows.iter()
413                        .flat_map(|r| r.iter().map(|c| measure_text_width(c))),
414                )
415                .max()
416                .unwrap_or(10);
417            content_max.min((term_width.saturating_sub(total_padding)) / col_count)
418        }
419    };
420
421    let border = match style {
422        TableStyle::Ascii => BorderSet::ascii(),
423        TableStyle::Rounded => BorderSet::rounded(),
424        TableStyle::Heavy => BorderSet::heavy(),
425    };
426
427    let mut out = String::with_capacity(128);
428
429    // Top Border
430    out.push(border.top_left);
431    for i in 0..col_count {
432        out.push_str(&border.horizontal.to_string().repeat(col_width));
433        if i < col_count - 1 {
434            out.push(border.top_cross);
435        }
436    }
437    out.push(border.top_right);
438    out.push('\n');
439
440    // Header Row
441    out.push(border.vertical);
442    for h in headers.iter() {
443        let a = pick_align(0, alignments);
444        let t = pick_trunc(0, trunc_modes);
445        out.push_str(&pad_cell_with(h, col_width, a, t));
446        out.push(border.vertical);
447    }
448    out.push('\n');
449
450    // Mid Border
451    out.push(border.mid_left);
452    for i in 0..col_count {
453        out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
454        if i < col_count - 1 {
455            out.push(border.mid_cross);
456        }
457    }
458    out.push(border.mid_right);
459    out.push('\n');
460
461    // Body Rows
462    for (ri, row) in rows.iter().enumerate() {
463        out.push(border.vertical);
464        for (ci, cell) in row.iter().enumerate() {
465            let a = pick_align(ci, alignments);
466            let t = pick_trunc(ci, trunc_modes);
467            let mut cell_s = pad_cell_with(cell, col_width, a, t);
468            if zebra && (ri % 2 == 1) {
469                // lightweight zebra: replace spaces in padding with middle dot for visibility
470                // keeps width identical
471                cell_s = cell_s.replace(' ', "·");
472            }
473            out.push_str(&cell_s);
474            out.push(border.vertical);
475        }
476        out.push('\n');
477
478        if row_separators && ri < rows.len() - 1 {
479            // Inner separator line between rows
480            out.push(border.mid_left);
481            for i in 0..col_count {
482                out.push_str(&border.inner_horizontal.to_string().repeat(col_width));
483                if i < col_count - 1 {
484                    out.push(border.mid_cross);
485                }
486            }
487            out.push(border.mid_right);
488            out.push('\n');
489        }
490    }
491
492    // Bottom Border
493    out.push(border.bottom_left);
494    for i in 0..col_count {
495        out.push_str(&border.horizontal.to_string().repeat(col_width));
496        if i < col_count - 1 {
497            out.push(border.bottom_cross);
498        }
499    }
500    out.push(border.bottom_right);
501    out.push('\n');
502
503    out
504}
505
506/// Column-specific width specification.
507#[derive(Clone, Copy)]
508pub enum ColWidth {
509    Fixed(usize),
510    Percent(u16),
511    Auto,
512}
513
514/// Render with explicit per-column widths.
515#[allow(clippy::too_many_arguments)]
516pub fn render_table_with_columns(
517    headers: &[&str],
518    rows: &[Vec<&str>],
519    style: TableStyle,
520    columns: &[ColWidth],
521    alignments: Option<&[Align]>,
522    trunc_modes: Option<&[TruncateMode]>,
523    zebra: bool,
524    row_separators: bool,
525) -> String {
526    let term_width = terminal_size()
527        .map(|(Width(w), _)| w as usize)
528        .unwrap_or(80);
529    let col_count = headers.len().max(1);
530    let padding: usize = 1;
531    let gaps_total = padding.saturating_mul(col_count.saturating_sub(1));
532
533    // Compute column widths
534    let mut widths = vec![0usize; col_count];
535    let mut fixed_total = 0usize;
536    let mut pct_total = 0u16;
537    let mut auto_count = 0usize;
538    for (i, spec) in columns.iter().enumerate().take(col_count) {
539        match spec {
540            ColWidth::Fixed(w) => {
541                widths[i] = *w;
542                fixed_total = fixed_total.saturating_add(*w);
543            }
544            ColWidth::Percent(p) => {
545                pct_total = pct_total.saturating_add(*p);
546            }
547            ColWidth::Auto => {
548                auto_count += 1;
549            }
550        }
551    }
552
553    let base_rem = term_width.saturating_sub(fixed_total + gaps_total);
554    // Assign percent columns proportional to base_rem
555    for (i, spec) in columns.iter().enumerate().take(col_count) {
556        if let ColWidth::Percent(p) = spec {
557            let w = ((base_rem as u128) * (*p as u128) / 100u128) as usize;
558            widths[i] = w;
559        }
560    }
561    // Remaining space goes to autos evenly
562    let used_except_auto: usize = widths.iter().sum();
563    let remaining = term_width.saturating_sub(used_except_auto + gaps_total);
564    let auto_share = if auto_count > 0 {
565        remaining / auto_count
566    } else {
567        0
568    };
569    for (i, spec) in columns.iter().enumerate().take(col_count) {
570        if matches!(spec, ColWidth::Auto) {
571            widths[i] = auto_share;
572        }
573    }
574
575    let border = match style {
576        TableStyle::Ascii => BorderSet::ascii(),
577        TableStyle::Rounded => BorderSet::rounded(),
578        TableStyle::Heavy => BorderSet::heavy(),
579    };
580    let mut out = String::with_capacity(128);
581
582    // Top
583    out.push(border.top_left);
584    for (i, w) in widths.iter().enumerate() {
585        out.push_str(&border.horizontal.to_string().repeat(*w));
586        if i < widths.len() - 1 {
587            out.push(border.top_cross);
588        }
589    }
590    out.push(border.top_right);
591    out.push('\n');
592
593    // Header
594    out.push(border.vertical);
595    for (ci, h) in headers.iter().enumerate() {
596        let a = pick_align(ci, alignments);
597        let t = pick_trunc(ci, trunc_modes);
598        out.push_str(&pad_cell_with(h, widths[ci].max(1), a, t));
599        out.push(border.vertical);
600    }
601    out.push('\n');
602
603    // Mid
604    out.push(border.mid_left);
605    for (i, w) in widths.iter().enumerate() {
606        out.push_str(&border.inner_horizontal.to_string().repeat(*w));
607        if i < widths.len() - 1 {
608            out.push(border.mid_cross);
609        }
610    }
611    out.push(border.mid_right);
612    out.push('\n');
613
614    // Rows
615    for (ri, row) in rows.iter().enumerate() {
616        out.push(border.vertical);
617        for (ci, cell) in row.iter().enumerate() {
618            let a = pick_align(ci, alignments);
619            let t = pick_trunc(ci, trunc_modes);
620            let mut cell_s = pad_cell_with(cell, widths[ci].max(1), a, t);
621            if zebra && (ri % 2 == 1) {
622                cell_s = cell_s.replace(' ', "·");
623            }
624            out.push_str(&cell_s);
625            out.push(border.vertical);
626        }
627        out.push('\n');
628
629        if row_separators && ri < rows.len() - 1 {
630            out.push(border.mid_left);
631            for (i, w) in widths.iter().enumerate() {
632                out.push_str(&border.inner_horizontal.to_string().repeat(*w));
633                if i < widths.len() - 1 {
634                    out.push(border.mid_cross);
635                }
636            }
637            out.push(border.mid_right);
638            out.push('\n');
639        }
640    }
641
642    // Bottom
643    out.push(border.bottom_left);
644    for (i, w) in widths.iter().enumerate() {
645        out.push_str(&border.horizontal.to_string().repeat(*w));
646        if i < widths.len() - 1 {
647            out.push(border.bottom_cross);
648        }
649    }
650    out.push(border.bottom_right);
651    out.push('\n');
652
653    out
654}
655
656/// Helper to pick alignment for a given column index with fallback.
657fn pick_align(idx: usize, aligns: Option<&[Align]>) -> Align {
658    aligns
659        .and_then(|arr| arr.get(idx).copied())
660        .unwrap_or(Align::Left)
661}
662
663/// Helper to pick truncate mode for a given column index with fallback.
664fn pick_trunc(idx: usize, truncs: Option<&[TruncateMode]>) -> TruncateMode {
665    truncs
666        .and_then(|arr| arr.get(idx).copied())
667        .unwrap_or(TruncateMode::End)
668}
669
670/// Truncates the cell to fit `width` characters visually, then pads according to `align`.
671fn pad_cell_with(cell: &str, width: usize, align: Align, trunc: TruncateMode) -> String {
672    let truncated = truncate_to_width_mode(cell, width, trunc);
673    let visual = measure_text_width(&truncated);
674    let pad = width.saturating_sub(visual);
675    match align {
676        Align::Left => format!("{truncated}{}", " ".repeat(pad)),
677        Align::Right => format!("{}{truncated}", " ".repeat(pad)),
678        Align::Center => {
679            let left = pad / 2;
680            let right = pad - left;
681            format!("{}{}{}", " ".repeat(left), truncated, " ".repeat(right))
682        }
683    }
684}
685
686/// Best-effort truncate that respects visual width using `console::measure_text_width`.
687/// If the content exceeds `width`, it trims to `width-1` and appends '…'.
688fn truncate_to_width_mode(cell: &str, width: usize, mode: TruncateMode) -> String {
689    if width == 0 {
690        return String::new();
691    }
692    let visual = measure_text_width(cell);
693    if visual <= width {
694        return cell.to_string();
695    }
696
697    // Reserve room for ellipsis
698    let target = width.saturating_sub(1);
699    // Work with grapheme clusters to avoid splitting emojis or accents
700    let g = UnicodeSegmentation::graphemes(cell, true).collect::<Vec<&str>>();
701    match mode {
702        TruncateMode::End => {
703            let mut out = String::new();
704            for gr in &g {
705                let next = format!("{out}{gr}");
706                if measure_text_width(&next) > target {
707                    break;
708                }
709                out.push_str(gr);
710            }
711            out.push('…');
712            out
713        }
714        TruncateMode::Start => {
715            let mut tail_rev: Vec<&str> = Vec::new();
716            for gr in g.iter().rev() {
717                let candidate = tail_rev
718                    .iter()
719                    .cloned()
720                    .rev()
721                    .chain(std::iter::once(*gr))
722                    .collect::<String>();
723                if measure_text_width(&candidate) > target {
724                    break;
725                }
726                tail_rev.push(gr);
727            }
728            let tail: String = tail_rev.into_iter().rev().collect();
729            format!("…{tail}")
730        }
731        TruncateMode::Middle => {
732            let mut head = String::new();
733            let mut tail_rev: Vec<&str> = Vec::new();
734            let mut left_i = 0usize;
735            let mut right_i = g.len();
736            loop {
737                let current = format!(
738                    "{head}…{}",
739                    tail_rev.iter().rev().cloned().collect::<String>()
740                );
741                if measure_text_width(&current) > width {
742                    break;
743                }
744                // Try extend head first
745                if left_i < right_i {
746                    let next = format!("{head}{}", g[left_i]);
747                    let cur2 = format!(
748                        "{next}…{}",
749                        tail_rev.iter().rev().cloned().collect::<String>()
750                    );
751                    if measure_text_width(&cur2) <= width {
752                        head.push_str(g[left_i]);
753                        left_i += 1;
754                        continue;
755                    }
756                }
757                // Then try extend tail
758                if right_i > left_i {
759                    let cand_tail = {
760                        let mut tmp = tail_rev.clone();
761                        if right_i > 0 {
762                            tmp.push(g[right_i - 1]);
763                        }
764                        tmp
765                    };
766                    let cur2 = format!(
767                        "{head}…{}",
768                        cand_tail.iter().rev().cloned().collect::<String>()
769                    );
770                    if measure_text_width(&cur2) <= width {
771                        tail_rev.push(g[right_i - 1]);
772                        right_i -= 1;
773                        continue;
774                    }
775                }
776                break;
777            }
778            format!(
779                "{head}…{}",
780                tail_rev.iter().rev().cloned().collect::<String>()
781            )
782        }
783    }
784}
785
786struct BorderSet {
787    top_left: char,
788    top_right: char,
789    bottom_left: char,
790    bottom_right: char,
791    top_cross: char,
792    bottom_cross: char,
793    mid_cross: char,
794    mid_left: char,
795    mid_right: char,
796    horizontal: char,
797    inner_horizontal: char,
798    vertical: char,
799}
800
801impl BorderSet {
802    fn ascii() -> Self {
803        Self {
804            top_left: '+',
805            top_right: '+',
806            bottom_left: '+',
807            bottom_right: '+',
808            top_cross: '+',
809            bottom_cross: '+',
810            mid_cross: '+',
811            mid_left: '+',
812            mid_right: '+',
813            horizontal: '-',
814            inner_horizontal: '-',
815            vertical: '|',
816        }
817    }
818
819    fn rounded() -> Self {
820        Self {
821            top_left: '╭',
822            top_right: '╮',
823            bottom_left: '╰',
824            bottom_right: '╯',
825            top_cross: '┬',
826            bottom_cross: '┴',
827            mid_cross: '┼',
828            mid_left: '├',
829            mid_right: '┤',
830            horizontal: '─',
831            inner_horizontal: '─',
832            vertical: '│',
833        }
834    }
835
836    fn heavy() -> Self {
837        Self {
838            top_left: '┏',
839            top_right: '┓',
840            bottom_left: '┗',
841            bottom_right: '┛',
842            top_cross: '┳',
843            bottom_cross: '┻',
844            mid_cross: '╋',
845            mid_left: '┣',
846            mid_right: '┫',
847            horizontal: '━',
848            inner_horizontal: '━',
849            vertical: '┃',
850        }
851    }
852}