Skip to main content

rich/
table.rs

1//! Tables.
2//!
3//! Port of upstream `rich/table.py` (core subset). A [`Table`] lays out columns
4//! and rows inside a box, sizing each column to its widest cell.
5//!
6//! Scope: headers, rows, box choice (with legacy/ASCII substitution), per-cell
7//! padding, **`pad_edge`** + **`show_edge`** + **`collapse_padding`**, header
8//! styling (incl. a per-column header-content span and a per-column header-cell
9//! fill), a **table-level style** + **border style**,
10//! multi-line/wrapped cells (with **ellipsis overflow**), **shrink-to-fit** +
11//! **expand** column widths, per-column justify, **explicit width**, per-column
12//! **`ratio`/`min_width`/`max_width`**, **per-column style**, **`no_wrap`**,
13//! title, caption, and `show_lines`. Deferred (tracked in the Table issue): the
14//! rare width-0 column padding edge.
15
16use 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
25/// A single column definition. Mirrors the used subset of `rich.table.Column`.
26struct Column {
27    header: String,
28    justify: Justify,
29    /// An explicit content width; when set, the column doesn't shrink to fit.
30    width: Option<usize>,
31    /// A style applied to this column's body cells.
32    style: Style,
33    /// An extra style span applied to the header *content* only (over the base
34    /// `header_style`), leaving the header padding as `header_style`. Mirrors
35    /// upstream stylizing the heading `Text` (e.g. `markdown.table.header`).
36    header_content_style: Option<Style>,
37    /// A per-column header *cell* style — combined over the table-level
38    /// `header_style` to fill the whole header cell (content + padding). Port of
39    /// `Column.header_style` (as used by e.g. rich-cli's numeric columns).
40    header_fill: Option<Style>,
41    /// When set, the column flexes to this share of the free width when the table
42    /// is `expand`ed (port of `Column.ratio`; makes the column "flexible").
43    ratio: Option<usize>,
44    /// A floor on the column's content width (port of `Column.min_width`).
45    min_width: Option<usize>,
46    /// A cap on the column's content width — wider cells wrap (port of
47    /// `Column.max_width`).
48    max_width: Option<usize>,
49    /// When set, cells are never wrapped — they crop to one line (with ellipsis).
50    no_wrap: bool,
51}
52
53/// A grid of cells rendered inside a box. Mirrors `rich.table.Table`.
54pub 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    /// Choose the box-drawing set.
100    pub fn box_set(mut self, box_set: BoxSet) -> Self {
101        self.box_set = box_set;
102        self
103    }
104
105    /// Style the box border (edges + dividers). Composed over the table-level
106    /// style: `border = style + border_style`. Port of `Table(border_style=…)`.
107    pub fn border_style(mut self, style: Style) -> Self {
108        self.border_style = style;
109        self
110    }
111
112    /// Whether to render the header row.
113    pub fn show_header(mut self, show: bool) -> Self {
114        self.show_header = show;
115        self
116    }
117
118    /// Expand the table to fill the available width.
119    pub fn expand(mut self, expand: bool) -> Self {
120        self.expand = expand;
121        self
122    }
123
124    /// Draw a separator line between each body row.
125    pub fn show_lines(mut self, show: bool) -> Self {
126        self.show_lines = show;
127        self
128    }
129
130    /// Draw the outer box edges (top/bottom borders + left/right glyphs). When
131    /// off, only the internal dividers and content remain. Port of `show_edge`.
132    pub fn show_edge(mut self, show: bool) -> Self {
133        self.show_edge = show;
134        self
135    }
136
137    /// Pad the outer cell edges. When off, the first column drops its left pad
138    /// and the last column its right pad. Port of `pad_edge`.
139    pub fn pad_edge(mut self, pad: bool) -> Self {
140        self.pad_edge = pad;
141        self
142    }
143
144    /// Merge adjacent cell padding: an interior column's left pad is reduced by
145    /// the previous column's right pad. Port of `collapse_padding`.
146    pub fn collapse_padding(mut self, collapse: bool) -> Self {
147        self.collapse_padding = collapse;
148        self
149    }
150
151    /// Default style for the whole table. Upstream applies it as the base of the
152    /// border style (`border_style = style + border_style`); cell content keeps
153    /// its own styles. Port of `Table(style=…)`.
154    pub fn style(mut self, style: Style) -> Self {
155        self.style = style;
156        self
157    }
158
159    /// The `(left, right)` padding for column `index` of `ncols`. Port of
160    /// `_get_padding_width` (collapse) combined with the `pad_edge` edge drops.
161    fn cell_padding(&self, index: usize, ncols: usize) -> (usize, usize) {
162        let (_, pr, _, pl) = self.padding;
163        // collapse_padding: interior columns shed the overlap with the previous
164        // column's right pad.
165        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        // pad_edge: the outer edges lose their padding.
172        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    /// A centered title rendered above the table.
182    pub fn title(mut self, title: impl Into<String>) -> Self {
183        self.title = Some(title.into());
184        self
185    }
186
187    /// A centered caption rendered below the table.
188    pub fn caption(mut self, caption: impl Into<String>) -> Self {
189        self.caption = Some(caption.into());
190        self
191    }
192
193    /// Add a left-justified column with the given header.
194    pub fn add_column(&mut self, header: impl Into<String>) -> &mut Self {
195        self.add_column_justify(header, Justify::Left)
196    }
197
198    /// Add a column with an explicit justification.
199    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    /// Pin the most-recently-added column to an explicit content width. Content
216    /// wider than this wraps (with ellipsis overflow) instead of shrinking the
217    /// column. Chain after `add_column`.
218    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    /// Give the most-recently-added column a flex `ratio`: when the table is
226    /// `expand`ed, ratio columns share the free width in proportion. Chain after
227    /// `add_column`. Port of `Column.ratio`.
228    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    /// Set a minimum content width on the most-recently-added column. Chain after
236    /// `add_column`. Port of `Column.min_width`.
237    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    /// Set a maximum content width on the most-recently-added column — wider
245    /// cells wrap. Chain after `add_column`. Port of `Column.max_width`.
246    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    /// Apply a style to the most-recently-added column's body cells. Chain after
254    /// `add_column`.
255    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    /// Style the most-recently-added column's header *content* (the visible
263    /// characters), leaving its padding as the base `header_style`. Chain after
264    /// `add_column`. Mirrors upstream stylizing the heading `Text`.
265    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    /// Style the most-recently-added column's whole header *cell* (content +
273    /// padding), combined over the table-level `header_style`. Chain after
274    /// `add_column`. Port of `Column.header_style`.
275    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    /// Mark the most-recently-added column `no_wrap`: its cells crop to a single
283    /// line (with ellipsis) instead of wrapping. Chain after `add_column`.
284    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    /// Add a row of cells (extra cells are ignored; missing cells render empty).
292    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    /// The measured content width of each column (widest cell, header included).
299    /// Widest *line* of a cell, not the width of the whole string.
300    ///
301    /// A cell spanning several lines occupies its widest line, exactly as
302    /// `Measurement.get` on a `Text` does. Measuring the raw string instead made
303    /// a multi-line cell as wide as all its lines **summed** — `\n` measures
304    /// zero, so nothing capped it — and a quoted CSV cell holding two sentences
305    /// blew its column out to 31 cells where upstream gives 23.
306    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    /// The rendered width (content + padding) of each column, shrinking the
328    /// widest columns to fit `available` when necessary. Port of the non-flexible
329    /// path of `Table._calculate_column_widths` + `_collapse_widths`.
330    fn column_widths(&self, available: usize) -> Vec<usize> {
331        let ncols = self.columns.len();
332        // A fixed-width column uses its declared width; others measure content,
333        // clamped to the column's [min_width, max_width]. Port of `_measure_column`.
334        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        // Expand with explicit ratios: flexible (ratio) columns share the free
360        // width in proportion, fixed columns keep their measured width. Port of
361        // the `if self.expand: … if any(ratios)` block of `_calculate_column_widths`.
362        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            // Only auto-width, wrapping columns may shrink; fixed and no_wrap
399            // columns hold their width (no_wrap only yields via the last resort).
400            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            // Last resort: if fixed columns still overflow, reduce every column
407            // evenly. Port of `_calculate_column_widths`'s final `ratio_reduce`.
408            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        // Expand: distribute the leftover width proportionally. Port of the
417        // `expand` tail of `_calculate_column_widths` (via `ratio_distribute`).
418        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    /// `cell_padding` shrunk so that padding alone can never exceed the width
429    /// the column was actually allotted.
430    ///
431    /// When many columns compete for a narrow terminal a column can be squeezed
432    /// below its own padding. The cell then still emitted a full left and right
433    /// pad, so every such column spent two cells where its border spent one and
434    /// the content row grew wider than the table — at 29 columns in an 80-cell
435    /// terminal the row overflowed by 15 cells and was cropped, taking the
436    /// right-hand border with it while the border rows kept theirs.
437    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    /// The effective style for a cell in column `index`: the header style for a
452    /// header row, else that column's own style.
453    fn cell_style(&self, index: usize, is_header: bool) -> Style {
454        if is_header {
455            // A per-column header cell style is combined over the table-level one.
456            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    /// Render one table row (a list of cell strings) into visual lines.
469    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        // Horizontal padding is per-column (see `cell_padding`); only the
478        // top/bottom vertical padding is uniform.
479        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        // Derived here rather than by the caller so the padding used to lay the
484        // row out is the same padding the content width was reduced by.
485        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        // Render each cell into padded, simplified visual lines.
498        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            // A no_wrap cell is one ellipsis-cropped line; otherwise wrap with
508            // ellipsis overflow (the table default). Then justify + pad.
509            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            // Header content carries its own style span over `header_style`; the
516            // justify/edge padding stays `header_style` (matches upstream).
517            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            // Vertical padding (blank content lines top/bottom).
528            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        // Pad every column to the row height with blank lines.
545        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        // `r` indexes into each column's per-line vector, so a range loop is the
558        // natural shape here (the columns are iterated with `enumerate`).
559        #[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        // Fall back to a terminal-safe box on legacy Windows / non-UTF-8.
593        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        // Borders occupy: (ncols-1) dividers, plus 2 outer edges when shown.
600        // Port of `_extra_width`.
601        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        // Full table width (for centering title/caption): columns + borders.
608        let table_width: usize = rendered_widths.iter().sum::<usize>() + extra_width;
609
610        let mut lines: Vec<Vec<Segment>> = Vec::new();
611
612        // Title, centered above the table.
613        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        // Caption, centered below the table.
669        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        // Join visual lines with newline segments (no trailing newline).
678        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
690/// Wrap `content` to `width` cells with **ellipsis overflow** (the table
691/// default): words are broken between, and a single word wider than `width` is
692/// cropped with a trailing `…`. Returns one string per visual line.
693fn wrap_cell(content: &str, width: usize) -> Vec<String> {
694    if width == 0 {
695        return vec![String::new()];
696    }
697    // Wrap each line of the cell on its own, as upstream's `Text.wrap` does —
698    // it splits on newlines before dividing. Handing the whole cell to
699    // `divide_line` treated the newline as ordinary whitespace worth zero cells,
700    // so it packed text from two source lines into one "line" that then printed
701    // as two rows: a 23-cell line inside a 23-cell column came out split.
702    if content.contains('\n') {
703        return content
704            .split('\n')
705            .flat_map(|line| wrap_cell(line, width))
706            .collect();
707    }
708    // `fold = false`: over-long words stay on their own (overflowing) line,
709    // which `ellipsis_crop` then trims — matching `Text(overflow="ellipsis")`.
710    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    // Trailing whitespace is dropped before the overflow check, so a word that
720    // fills the width exactly isn't spuriously ellipsized by its trailing space.
721    lines
722        .iter()
723        .map(|line| ellipsis_crop(line.trim_end(), width))
724        .collect()
725}
726
727/// Crop `text` to `width` cells, replacing the trailing cell with `…` when it
728/// doesn't fit. Port of the `overflow="ellipsis"` path of `Text.truncate`.
729fn 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
739/// Center `text` within `width` cells (floor-left), padding with spaces.
740fn 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
747/// Round half to even (banker's rounding), matching Python's `round`.
748fn 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
763/// Reduce `values` by `total`, distributed across slots by `ratios` (capped by
764/// `maximums`). Direct port of `rich._ratio.ratio_reduce`.
765fn 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
792/// Divide `total` across slots proportionally to `ratios` (ceil each share),
793/// each share floored at the matching `minimums` entry when given. Port of
794/// `rich._ratio.ratio_distribute`.
795fn ratio_distribute(total: i64, ratios: &[i64], minimums: Option<&[i64]>) -> Vec<i64> {
796    // Upstream zeroes the ratio of any slot whose minimum is 0 (falsy).
797    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            // ceil(ratio * total_remaining / total_ratio) for positive values,
812            // then floored at `minimum`.
813            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
826/// Reduce `widths` so their total is under `max_width`, shrinking the widest
827/// wrapable columns first. Direct port of `Table._collapse_widths`.
828fn 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    /// A column squeezed below its own padding still emitted a full left and
899    /// right pad, so each such column spent two cells where its border spent
900    /// one. The content row then overflowed the table and was cropped, losing
901    /// its right-hand border while the border rows kept theirs.
902    #[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    /// A cell spanning several lines occupies its WIDEST line. Measuring the raw
930    /// string made it as wide as all its lines summed — `\n` measures zero, so
931    /// nothing capped it — and a quoted CSV cell holding two sentences blew its
932    /// column out to 31 cells where upstream gives 23.
933    #[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        // "line two is much longer" is 23 cells; summing both lines would be 31.
945        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}