1use crate::cells::{cell_len, set_cell_size};
17use crate::console::{Console, ConsoleOptions, Justify};
18use crate::protocol::Renderable;
19use crate::r#box::{Box as BoxSet, RowLevel, HEAVY_HEAD};
20use crate::segment::Segment;
21use crate::style::Style;
22use crate::text::Text;
23use crate::theme::Theme;
24
25struct Column {
27 header: String,
28 justify: Justify,
29 width: Option<usize>,
31 style: Style,
33 header_content_style: Option<Style>,
37 header_fill: Option<Style>,
41 ratio: Option<usize>,
44 min_width: Option<usize>,
46 max_width: Option<usize>,
49 no_wrap: bool,
51}
52
53pub struct Table {
55 columns: Vec<Column>,
56 rows: Vec<Vec<String>>,
57 box_set: BoxSet,
58 show_header: bool,
59 show_lines: bool,
60 show_edge: bool,
61 pad_edge: bool,
62 collapse_padding: bool,
63 expand: bool,
64 title: Option<String>,
65 caption: Option<String>,
66 padding: (usize, usize, usize, usize),
67 header_style: Style,
68 border_style: Style,
69 style: Style,
70}
71
72impl Default for Table {
73 fn default() -> Self {
74 Table {
75 columns: Vec::new(),
76 rows: Vec::new(),
77 box_set: HEAVY_HEAD,
78 show_header: true,
79 show_lines: false,
80 show_edge: true,
81 pad_edge: true,
82 collapse_padding: false,
83 expand: false,
84 title: None,
85 caption: None,
86 padding: (0, 1, 0, 1),
87 header_style: Style::parse("bold").expect("valid built-in style"),
88 border_style: Style::new(),
89 style: Style::new(),
90 }
91 }
92}
93
94impl Table {
95 pub fn new() -> Self {
96 Table::default()
97 }
98
99 pub fn box_set(mut self, box_set: BoxSet) -> Self {
101 self.box_set = box_set;
102 self
103 }
104
105 pub fn border_style(mut self, style: Style) -> Self {
108 self.border_style = style;
109 self
110 }
111
112 pub fn show_header(mut self, show: bool) -> Self {
114 self.show_header = show;
115 self
116 }
117
118 pub fn expand(mut self, expand: bool) -> Self {
120 self.expand = expand;
121 self
122 }
123
124 pub fn show_lines(mut self, show: bool) -> Self {
126 self.show_lines = show;
127 self
128 }
129
130 pub fn show_edge(mut self, show: bool) -> Self {
133 self.show_edge = show;
134 self
135 }
136
137 pub fn pad_edge(mut self, pad: bool) -> Self {
140 self.pad_edge = pad;
141 self
142 }
143
144 pub fn collapse_padding(mut self, collapse: bool) -> Self {
147 self.collapse_padding = collapse;
148 self
149 }
150
151 pub fn style(mut self, style: Style) -> Self {
155 self.style = style;
156 self
157 }
158
159 fn cell_padding(&self, index: usize, ncols: usize) -> (usize, usize) {
162 let (_, pr, _, pl) = self.padding;
163 let mut left = if self.collapse_padding && index > 0 {
166 pl.saturating_sub(pr)
167 } else {
168 pl
169 };
170 let mut right = pr;
171 if !self.pad_edge && index == 0 {
173 left = 0;
174 }
175 if !self.pad_edge && index + 1 == ncols {
176 right = 0;
177 }
178 (left, right)
179 }
180
181 pub fn title(mut self, title: impl Into<String>) -> Self {
183 self.title = Some(title.into());
184 self
185 }
186
187 pub fn caption(mut self, caption: impl Into<String>) -> Self {
189 self.caption = Some(caption.into());
190 self
191 }
192
193 pub fn add_column(&mut self, header: impl Into<String>) -> &mut Self {
195 self.add_column_justify(header, Justify::Left)
196 }
197
198 pub fn add_column_justify(&mut self, header: impl Into<String>, justify: Justify) -> &mut Self {
200 self.columns.push(Column {
201 header: header.into(),
202 justify,
203 width: None,
204 style: Style::new(),
205 header_content_style: None,
206 header_fill: None,
207 ratio: None,
208 min_width: None,
209 max_width: None,
210 no_wrap: false,
211 });
212 self
213 }
214
215 pub fn column_width(&mut self, width: usize) -> &mut Self {
219 if let Some(column) = self.columns.last_mut() {
220 column.width = Some(width);
221 }
222 self
223 }
224
225 pub fn column_ratio(&mut self, ratio: usize) -> &mut Self {
229 if let Some(column) = self.columns.last_mut() {
230 column.ratio = Some(ratio);
231 }
232 self
233 }
234
235 pub fn column_min_width(&mut self, min_width: usize) -> &mut Self {
238 if let Some(column) = self.columns.last_mut() {
239 column.min_width = Some(min_width);
240 }
241 self
242 }
243
244 pub fn column_max_width(&mut self, max_width: usize) -> &mut Self {
247 if let Some(column) = self.columns.last_mut() {
248 column.max_width = Some(max_width);
249 }
250 self
251 }
252
253 pub fn column_style(&mut self, style: Style) -> &mut Self {
256 if let Some(column) = self.columns.last_mut() {
257 column.style = style;
258 }
259 self
260 }
261
262 pub fn column_header_style(&mut self, style: Style) -> &mut Self {
266 if let Some(column) = self.columns.last_mut() {
267 column.header_content_style = Some(style);
268 }
269 self
270 }
271
272 pub fn column_header_fill(&mut self, style: Style) -> &mut Self {
276 if let Some(column) = self.columns.last_mut() {
277 column.header_fill = Some(style);
278 }
279 self
280 }
281
282 pub fn column_no_wrap(&mut self) -> &mut Self {
285 if let Some(column) = self.columns.last_mut() {
286 column.no_wrap = true;
287 }
288 self
289 }
290
291 pub fn add_row(&mut self, cells: &[&str]) -> &mut Self {
293 self.rows
294 .push(cells.iter().map(|s| s.to_string()).collect());
295 self
296 }
297
298 fn block_width(text: &str) -> usize {
307 text.split('\n').map(cell_len).max().unwrap_or(0)
308 }
309
310 fn max_content_widths(&self) -> Vec<usize> {
311 let mut widths = vec![0usize; self.columns.len()];
312 for (index, column) in self.columns.iter().enumerate() {
313 if self.show_header {
314 widths[index] = Self::block_width(&column.header);
315 }
316 }
317 for row in &self.rows {
318 for (index, cell) in row.iter().enumerate() {
319 if index < widths.len() {
320 widths[index] = widths[index].max(Self::block_width(cell));
321 }
322 }
323 }
324 widths
325 }
326
327 fn column_widths(&self, available: usize) -> Vec<usize> {
331 let ncols = self.columns.len();
332 let content = self.max_content_widths();
335 let mut widths: Vec<i64> = self
336 .columns
337 .iter()
338 .zip(&content)
339 .enumerate()
340 .map(|(index, (column, &measured))| {
341 let (pl, pr) = self.cell_padding(index, ncols);
342 let content_width = match column.width {
343 Some(w) => w,
344 None => {
345 let mut w = measured;
346 if let Some(min) = column.min_width {
347 w = w.max(min);
348 }
349 if let Some(max) = column.max_width {
350 w = w.min(max);
351 }
352 w
353 }
354 };
355 (content_width + pl + pr) as i64
356 })
357 .collect();
358
359 if self.expand {
363 let ratios: Vec<i64> = self
364 .columns
365 .iter()
366 .filter(|c| c.ratio.is_some())
367 .map(|c| c.ratio.unwrap() as i64)
368 .collect();
369 if ratios.iter().any(|&r| r > 0) {
370 let fixed_widths: Vec<i64> = widths
371 .iter()
372 .zip(&self.columns)
373 .map(|(&w, c)| if c.ratio.is_some() { 0 } else { w })
374 .collect();
375 let flex_minimum: Vec<i64> = self
376 .columns
377 .iter()
378 .enumerate()
379 .filter(|(_, c)| c.ratio.is_some())
380 .map(|(index, c)| {
381 let (pl, pr) = self.cell_padding(index, ncols);
382 (c.width.unwrap_or(1) + pl + pr) as i64
383 })
384 .collect();
385 let flexible_width = available as i64 - fixed_widths.iter().sum::<i64>();
386 let flex_widths = ratio_distribute(flexible_width, &ratios, Some(&flex_minimum));
387 let mut iter_flex = flex_widths.into_iter();
388 for (index, column) in self.columns.iter().enumerate() {
389 if column.ratio.is_some() {
390 widths[index] = fixed_widths[index] + iter_flex.next().unwrap_or(0);
391 }
392 }
393 }
394 }
395
396 let table_width: i64 = widths.iter().sum();
397 if table_width > available as i64 {
398 let wrapable: Vec<bool> = self
401 .columns
402 .iter()
403 .map(|c| c.width.is_none() && !c.no_wrap)
404 .collect();
405 widths = collapse_widths(widths, &wrapable, available as i64);
406 let table_width: i64 = widths.iter().sum();
409 if table_width > available as i64 {
410 let excess = table_width - available as i64;
411 let ratios = vec![1i64; widths.len()];
412 widths = ratio_reduce(excess, &ratios, &widths, &widths);
413 }
414 }
415
416 let table_width: i64 = widths.iter().sum();
419 if self.expand && table_width < available as i64 && table_width > 0 {
420 let pad = ratio_distribute(available as i64 - table_width, &widths, None);
421 for (width, extra) in widths.iter_mut().zip(pad) {
422 *width += extra;
423 }
424 }
425 widths.into_iter().map(|w| w.max(0) as usize).collect()
426 }
427
428 fn cell_padding_fitted(&self, index: usize, ncols: usize, rendered: usize) -> (usize, usize) {
438 let (mut pl, mut pr) = self.cell_padding(index, ncols);
439 while pl + pr > rendered {
440 if pr > pl {
441 pr -= 1;
442 } else if pl > 0 {
443 pl -= 1;
444 } else {
445 break;
446 }
447 }
448 (pl, pr)
449 }
450
451 fn cell_style(&self, index: usize, is_header: bool) -> Style {
454 if is_header {
455 match self.columns.get(index).and_then(|c| c.header_fill.as_ref()) {
457 Some(fill) => self.header_style.combine(fill),
458 None => self.header_style.clone(),
459 }
460 } else {
461 self.columns
462 .get(index)
463 .map(|c| c.style.clone())
464 .unwrap_or_default()
465 }
466 }
467
468 fn render_row(
470 &self,
471 theme: &Theme,
472 cells: &[String],
473 rendered_widths: &[usize],
474 is_header: bool,
475 edges: (char, char, char),
476 ) -> Vec<Vec<Segment>> {
477 let (pt, _, pb, _) = self.padding;
480 let (edge_left, edge_vertical, edge_right) = edges;
481 let border = Some(self.style.combine(&self.border_style));
482 let ncols = self.columns.len();
483 let paddings: Vec<(usize, usize)> = (0..ncols)
486 .map(|index| {
487 let rendered = rendered_widths.get(index).copied().unwrap_or(0);
488 self.cell_padding_fitted(index, ncols, rendered)
489 })
490 .collect();
491 let content_widths: Vec<usize> = rendered_widths
492 .iter()
493 .zip(&paddings)
494 .map(|(w, (pl, pr))| w.saturating_sub(pl + pr))
495 .collect();
496
497 let mut cell_lines: Vec<Vec<Vec<Segment>>> = Vec::with_capacity(ncols);
499 let mut height = 1;
500 for (index, width) in content_widths.iter().enumerate() {
501 let style = self.cell_style(index, is_header);
502 let cell_fill = Some(style.clone());
503 let content = cells.get(index).map(String::as_str).unwrap_or("");
504 let column = self.columns.get(index);
505 let justify = column.map(|c| c.justify).unwrap_or(Justify::Left);
506 let no_wrap = column.map(|c| c.no_wrap).unwrap_or(false);
507 let wrapped = if no_wrap {
510 ellipsis_crop(content, *width)
511 } else {
512 wrap_cell(content, *width).join("\n")
513 };
514 let mut text = Text::new(wrapped).justify(justify);
515 if is_header {
518 if let Some(span) = column.and_then(|c| c.header_content_style.clone()) {
519 let len = text.plain().len();
520 text.stylize(span, 0, len);
521 }
522 }
523 let mut lines = text.render_lines(theme, &style, Some(*width));
524 if lines.is_empty() {
525 lines.push(Vec::new());
526 }
527 let blank = || Segment::new(" ".repeat(*width), cell_fill.clone());
529 let mut padded_lines: Vec<Vec<Segment>> = Vec::new();
530 for _ in 0..pt {
531 padded_lines.push(vec![blank()]);
532 }
533 for line in &lines {
534 let padded = Segment::adjust_line_length(line, *width, cell_fill.clone());
535 padded_lines.push(Segment::simplify(&padded));
536 }
537 for _ in 0..pb {
538 padded_lines.push(vec![blank()]);
539 }
540 height = height.max(padded_lines.len());
541 cell_lines.push(padded_lines);
542 }
543
544 for (index, lines) in cell_lines.iter_mut().enumerate() {
546 let fill = Some(self.cell_style(index, is_header));
547 while lines.len() < height {
548 lines.push(vec![Segment::new(
549 " ".repeat(content_widths[index]),
550 fill.clone(),
551 )]);
552 }
553 }
554
555 let last = ncols.saturating_sub(1);
556 let mut rows_out: Vec<Vec<Segment>> = Vec::with_capacity(height);
557 #[allow(clippy::needless_range_loop)]
560 for r in 0..height {
561 let mut row = Vec::new();
562 if self.show_edge {
563 row.push(Segment::new(edge_left.to_string(), border.clone()));
564 }
565 for (c, column_lines) in cell_lines.iter().enumerate() {
566 let fill = Some(self.cell_style(c, is_header));
567 let (cpl, cpr) = paddings[c];
568 if cpl > 0 {
569 row.push(Segment::new(" ".repeat(cpl), fill.clone()));
570 }
571 row.extend(column_lines[r].clone());
572 if cpr > 0 {
573 row.push(Segment::new(" ".repeat(cpr), fill.clone()));
574 }
575 if c != last {
576 row.push(Segment::new(edge_vertical.to_string(), border.clone()));
577 } else if self.show_edge {
578 row.push(Segment::new(edge_right.to_string(), border.clone()));
579 }
580 }
581 rows_out.push(row);
582 }
583 rows_out
584 }
585}
586
587impl Renderable for Table {
588 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
589 if self.columns.is_empty() {
590 return Vec::new();
591 }
592 let box_set = self.box_set.substitute(
594 console.legacy_windows(),
595 console.safe_box(),
596 console.ascii_only(),
597 );
598 let ncols = self.columns.len();
599 let extra_width = (if self.show_edge { 2 } else { 0 }) + ncols.saturating_sub(1);
602 let available = options.max_width.saturating_sub(extra_width);
603
604 let rendered_widths = self.column_widths(available);
605 let border = Some(self.style.combine(&self.border_style));
606
607 let table_width: usize = rendered_widths.iter().sum::<usize>() + extra_width;
609
610 let mut lines: Vec<Vec<Segment>> = Vec::new();
611
612 if let Some(title) = &self.title {
614 let style = Style::parse("italic").expect("valid built-in style");
615 lines.push(vec![Segment::new(center(title, table_width), Some(style))]);
616 }
617
618 let edge = self.show_edge;
619 if edge {
620 lines.push(vec![Segment::new(
621 box_set.get_top(&rendered_widths, edge),
622 border.clone(),
623 )]);
624 }
625
626 let head_edges = (box_set.head_left, box_set.head_vertical, box_set.head_right);
627 let body_edges = (box_set.mid_left, box_set.mid_vertical, box_set.mid_right);
628
629 if self.show_header {
630 let headers: Vec<String> = self.columns.iter().map(|c| c.header.clone()).collect();
631 lines.extend(self.render_row(
632 console.theme(),
633 &headers,
634 &rendered_widths,
635 true,
636 head_edges,
637 ));
638 lines.push(vec![Segment::new(
639 box_set.get_row(&rendered_widths, RowLevel::Head, edge),
640 border.clone(),
641 )]);
642 }
643
644 let row_last = self.rows.len().saturating_sub(1);
645 for (index, row) in self.rows.iter().enumerate() {
646 lines.extend(self.render_row(
647 console.theme(),
648 row,
649 &rendered_widths,
650 false,
651 body_edges,
652 ));
653 if self.show_lines && index != row_last {
654 lines.push(vec![Segment::new(
655 box_set.get_row(&rendered_widths, RowLevel::Row, edge),
656 border.clone(),
657 )]);
658 }
659 }
660
661 if edge {
662 lines.push(vec![Segment::new(
663 box_set.get_bottom(&rendered_widths, edge),
664 border.clone(),
665 )]);
666 }
667
668 if let Some(caption) = &self.caption {
670 let style = Style::parse("dim italic").expect("valid built-in style");
671 lines.push(vec![Segment::new(
672 center(caption, table_width),
673 Some(style),
674 )]);
675 }
676
677 let mut segments = Vec::new();
679 let last = lines.len().saturating_sub(1);
680 for (index, line) in lines.into_iter().enumerate() {
681 segments.extend(line);
682 if index != last {
683 segments.push(Segment::line());
684 }
685 }
686 segments
687 }
688}
689
690fn wrap_cell(content: &str, width: usize) -> Vec<String> {
694 if width == 0 {
695 return vec![String::new()];
696 }
697 if content.contains('\n') {
703 return content
704 .split('\n')
705 .flat_map(|line| wrap_cell(line, width))
706 .collect();
707 }
708 let breaks = crate::wrap::divide_line(content, width, false);
711 let chars: Vec<char> = content.chars().collect();
712 let mut lines: Vec<String> = Vec::new();
713 let mut start = 0;
714 for stop in breaks {
715 lines.push(chars[start..stop].iter().collect());
716 start = stop;
717 }
718 lines.push(chars[start..].iter().collect());
719 lines
722 .iter()
723 .map(|line| ellipsis_crop(line.trim_end(), width))
724 .collect()
725}
726
727fn ellipsis_crop(text: &str, width: usize) -> String {
730 if cell_len(text) <= width {
731 return text.to_string();
732 }
733 if width == 0 {
734 return String::new();
735 }
736 format!("{}\u{2026}", set_cell_size(text, width - 1))
737}
738
739fn center(text: &str, width: usize) -> String {
741 let excess = width.saturating_sub(cell_len(text));
742 let left = excess / 2;
743 let right = excess - left;
744 format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
745}
746
747fn round_half_even(value: f64) -> i64 {
749 let floor = value.floor();
750 let diff = value - floor;
751 if (diff - 0.5).abs() < 1e-9 {
752 let f = floor as i64;
753 if f % 2 == 0 {
754 f
755 } else {
756 f + 1
757 }
758 } else {
759 value.round() as i64
760 }
761}
762
763fn ratio_reduce(total: i64, ratios: &[i64], maximums: &[i64], values: &[i64]) -> Vec<i64> {
766 let ratios: Vec<i64> = ratios
767 .iter()
768 .zip(maximums)
769 .map(|(&r, &m)| if m != 0 { r } else { 0 })
770 .collect();
771 let mut total_ratio: i64 = ratios.iter().sum();
772 if total_ratio == 0 {
773 return values.to_vec();
774 }
775 let mut total_remaining = total;
776 let mut result = Vec::with_capacity(values.len());
777 for ((&ratio, &maximum), &value) in ratios.iter().zip(maximums).zip(values) {
778 if ratio != 0 && total_ratio > 0 {
779 let distributed = maximum.min(round_half_even(
780 ratio as f64 * total_remaining as f64 / total_ratio as f64,
781 ));
782 result.push(value - distributed);
783 total_remaining -= distributed;
784 total_ratio -= ratio;
785 } else {
786 result.push(value);
787 }
788 }
789 result
790}
791
792fn ratio_distribute(total: i64, ratios: &[i64], minimums: Option<&[i64]>) -> Vec<i64> {
796 let ratios: Vec<i64> = match minimums {
798 Some(mins) => ratios
799 .iter()
800 .zip(mins)
801 .map(|(&r, &m)| if m != 0 { r } else { 0 })
802 .collect(),
803 None => ratios.to_vec(),
804 };
805 let mut total_ratio: i64 = ratios.iter().sum();
806 let mut total_remaining = total;
807 let mut result = Vec::with_capacity(ratios.len());
808 for (index, &ratio) in ratios.iter().enumerate() {
809 let minimum = minimums.map_or(0, |m| m[index]);
810 let distributed = if total_ratio > 0 {
811 let numerator = ratio * total_remaining;
814 let ceil_div = (numerator + total_ratio - 1) / total_ratio;
815 minimum.max(ceil_div)
816 } else {
817 total_remaining
818 };
819 result.push(distributed);
820 total_ratio -= ratio;
821 total_remaining -= distributed;
822 }
823 result
824}
825
826fn collapse_widths(mut widths: Vec<i64>, wrapable: &[bool], max_width: i64) -> Vec<i64> {
829 let mut total_width: i64 = widths.iter().sum();
830 let mut excess_width = total_width - max_width;
831 if wrapable.iter().any(|&w| w) {
832 while total_width != 0 && excess_width > 0 {
833 let max_column = widths
834 .iter()
835 .zip(wrapable)
836 .filter(|(_, &w)| w)
837 .map(|(&x, _)| x)
838 .max()
839 .unwrap_or(0);
840 let second_max_column = widths
841 .iter()
842 .zip(wrapable)
843 .map(|(&x, &w)| if w && x != max_column { x } else { 0 })
844 .max()
845 .unwrap_or(0);
846 let column_difference = max_column - second_max_column;
847 let ratios: Vec<i64> = widths
848 .iter()
849 .zip(wrapable)
850 .map(|(&x, &w)| i64::from(x == max_column && w))
851 .collect();
852 if !ratios.iter().any(|&r| r != 0) || column_difference == 0 {
853 break;
854 }
855 let max_reduce = vec![excess_width.min(column_difference); widths.len()];
856 widths = ratio_reduce(excess_width, &ratios, &max_reduce, &widths);
857 total_width = widths.iter().sum();
858 excess_width = total_width - max_width;
859 }
860 }
861 widths
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867 use crate::color::ColorSystem;
868 use crate::r#box::SQUARE;
869
870 fn console() -> Console {
871 Console::builder()
872 .force_terminal(true)
873 .color_system(Some(ColorSystem::Truecolor))
874 .width(40)
875 .no_color(false)
876 .build()
877 }
878
879 #[test]
880 fn simple_square_table() {
881 let mut table = Table::new().box_set(SQUARE);
882 table.add_column("Name");
883 table.add_column("Age");
884 table.add_row(&["Alice", "30"]);
885 table.add_row(&["Bob", "7"]);
886 let out = console().render_export(&table);
887 let expected = concat!(
888 "┌───────┬─────┐\n",
889 "│\x1b[1m \x1b[0m\x1b[1mName \x1b[0m\x1b[1m \x1b[0m│\x1b[1m \x1b[0m\x1b[1mAge\x1b[0m\x1b[1m \x1b[0m│\n",
890 "├───────┼─────┤\n",
891 "│ Alice │ 30 │\n",
892 "│ Bob │ 7 │\n",
893 "└───────┴─────┘\n",
894 );
895 assert_eq!(out, expected);
896 }
897
898 #[test]
903 fn a_column_narrower_than_its_padding_stays_inside_the_border() {
904 for ncols in [20usize, 29, 40] {
905 let mut table = Table::new().box_set(SQUARE);
906 for i in 0..ncols {
907 table.add_column(format!("c{i}"));
908 }
909 let row: Vec<String> = (0..ncols).map(|i| i.to_string()).collect();
910 table.add_row(&row.iter().map(String::as_str).collect::<Vec<_>>());
911 let console = Console::builder().width(80).no_color(true).build();
912 let out = console.render_to_string(&table);
913 let rows: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
914 let widths: Vec<usize> = rows.iter().map(|r| r.chars().count()).collect();
915 assert!(
916 widths.iter().all(|w| *w == widths[0]),
917 "{ncols} columns produced ragged rows: {widths:?}"
918 );
919 for (index, row) in rows.iter().enumerate() {
920 let last = row.chars().last().expect("non-empty row");
921 assert!(
922 !last.is_whitespace(),
923 "{ncols} columns: row {index} lost its right border: {row:?}"
924 );
925 }
926 }
927 }
928
929 #[test]
934 fn a_multi_line_cell_is_measured_by_its_widest_line() {
935 let mut table = Table::new().box_set(SQUARE);
936 table.add_column("name");
937 table.add_column("bio");
938 table.add_row(&["Alice", "line one\nline two is much longer"]);
939 table.add_row(&["Bob", "short"]);
940 let console = Console::builder().width(60).no_color(true).build();
941 let out = console.render_to_string(&table);
942 let top = out.lines().next().expect("a top border");
943 let width = top.chars().count();
944 assert!(
946 width < 40,
947 "the multi-line cell was measured as the sum of its lines: {width} wide"
948 );
949 assert!(
950 out.contains("line two is much longer"),
951 "content lost: {out:?}"
952 );
953 }
954}