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, Overflow};
18use crate::protocol::{LineRenderable, Renderable};
19use crate::r#box::{Box as BoxSet, RowLevel, HEAVY_HEAD};
20use crate::segment::Segment;
21use crate::style::Style;
22use crate::text::{Text, DEFAULT_TAB_SIZE};
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 LineRenderable for Table {
588    /// Render visual lines in order without retaining the full rendered table.
589    ///
590    /// Like upstream's `Table.__rich_console__` / `_render` generators, this
591    /// measures all columns first, then renders only one row block at a time.
592    /// Lines contain styled segments without a trailing newline. The callback
593    /// may write each line immediately; its first error stops rendering.
594    /// The table still owns its source rows for column-width measurement.
595    fn try_for_each_line<E>(
596        &self,
597        console: &Console,
598        options: &ConsoleOptions,
599        mut emit: impl FnMut(Vec<Segment>) -> Result<(), E>,
600    ) -> Result<(), E> {
601        if self.columns.is_empty() {
602            return emit(vec![Segment::new("", None)]);
603        }
604        // Fall back to a terminal-safe box on legacy Windows / non-UTF-8.
605        let box_set = self.box_set.substitute(
606            console.legacy_windows(),
607            console.safe_box(),
608            console.ascii_only(),
609        );
610        let ncols = self.columns.len();
611        // Borders occupy: (ncols-1) dividers, plus 2 outer edges when shown.
612        // Port of `_extra_width`.
613        let extra_width = (if self.show_edge { 2 } else { 0 }) + ncols.saturating_sub(1);
614        let available = options.max_width.saturating_sub(extra_width);
615
616        let rendered_widths = self.column_widths(available);
617        let border = Some(self.style.combine(&self.border_style));
618
619        // Full table width (for centering title/caption): columns + borders.
620        let table_width: usize = rendered_widths.iter().sum::<usize>() + extra_width;
621
622        // Title, centered above the table.
623        if let Some(title) = self.title.as_ref().filter(|title| !title.is_empty()) {
624            for line in render_annotation(console, options, title, "table.title", table_width) {
625                emit(line)?;
626            }
627        }
628
629        let edge = self.show_edge;
630        if edge {
631            emit(vec![Segment::new(
632                box_set.get_top(&rendered_widths, edge),
633                border.clone(),
634            )])?;
635        }
636
637        let head_edges = (box_set.head_left, box_set.head_vertical, box_set.head_right);
638        let body_edges = (box_set.mid_left, box_set.mid_vertical, box_set.mid_right);
639
640        if self.show_header {
641            let headers: Vec<String> = self.columns.iter().map(|c| c.header.clone()).collect();
642            for line in self.render_row(
643                console.theme(),
644                &headers,
645                &rendered_widths,
646                true,
647                head_edges,
648            ) {
649                emit(line)?;
650            }
651            emit(vec![Segment::new(
652                box_set.get_row(&rendered_widths, RowLevel::Head, edge),
653                border.clone(),
654            )])?;
655        }
656
657        let row_last = self.rows.len().saturating_sub(1);
658        for (index, row) in self.rows.iter().enumerate() {
659            for line in self.render_row(console.theme(), row, &rendered_widths, false, body_edges) {
660                emit(line)?;
661            }
662            if self.show_lines && index != row_last {
663                emit(vec![Segment::new(
664                    box_set.get_row(&rendered_widths, RowLevel::Row, edge),
665                    border.clone(),
666                )])?;
667            }
668        }
669
670        if edge {
671            emit(vec![Segment::new(
672                box_set.get_bottom(&rendered_widths, edge),
673                border.clone(),
674            )])?;
675        }
676
677        // Caption, centered below the table.
678        if let Some(caption) = self.caption.as_ref().filter(|caption| !caption.is_empty()) {
679            for line in render_annotation(console, options, caption, "table.caption", table_width) {
680                emit(line)?;
681            }
682        }
683
684        Ok(())
685    }
686}
687
688impl crate::protocol::OwnedTableRows for Table {
689    fn extend_owned_rows(&mut self, mut rows: Vec<Vec<String>>) -> &mut Self {
690        if self.rows.is_empty() {
691            self.rows = rows;
692        } else {
693            self.rows.append(&mut rows);
694        }
695        self
696    }
697}
698
699impl Renderable for Table {
700    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
701        let mut segments = Vec::new();
702        let mut first = true;
703        let result: Result<(), std::convert::Infallible> =
704            self.try_for_each_line(console, options, |line| {
705                if !first {
706                    segments.push(Segment::line());
707                }
708                first = false;
709                segments.extend(line);
710                Ok(())
711            });
712        match result {
713            Ok(()) => segments,
714            Err(never) => match never {},
715        }
716    }
717}
718
719/// Wrap `content` to `width` cells with **ellipsis overflow** (the table
720/// default): words are broken between, and a single word wider than `width` is
721/// cropped with a trailing `…`. Returns one string per visual line.
722fn wrap_cell(content: &str, width: usize) -> Vec<String> {
723    if width == 0 {
724        return vec![String::new()];
725    }
726    // Wrap each line of the cell on its own, as upstream's `Text.wrap` does —
727    // it splits on newlines before dividing. Handing the whole cell to
728    // `divide_line` treated the newline as ordinary whitespace worth zero cells,
729    // so it packed text from two source lines into one "line" that then printed
730    // as two rows: a 23-cell line inside a 23-cell column came out split.
731    if content.contains('\n') {
732        return content
733            .split('\n')
734            .flat_map(|line| wrap_cell(line, width))
735            .collect();
736    }
737    // `fold = false`: over-long words stay on their own (overflowing) line,
738    // which `ellipsis_crop` then trims — matching `Text(overflow="ellipsis")`.
739    let breaks = crate::wrap::divide_line(content, width, false);
740    let chars: Vec<char> = content.chars().collect();
741    let mut lines: Vec<String> = Vec::new();
742    let mut start = 0;
743    for stop in breaks {
744        lines.push(chars[start..stop].iter().collect());
745        start = stop;
746    }
747    lines.push(chars[start..].iter().collect());
748    // Trailing whitespace is dropped before the overflow check, so a word that
749    // fills the width exactly isn't spuriously ellipsized by its trailing space.
750    lines
751        .iter()
752        .map(|line| ellipsis_crop(line.trim_end(), width))
753        .collect()
754}
755
756/// Crop `text` to `width` cells, replacing the trailing cell with `…` when it
757/// doesn't fit. Port of the `overflow="ellipsis"` path of `Text.truncate`.
758fn ellipsis_crop(text: &str, width: usize) -> String {
759    if cell_len(text) <= width {
760        return text.to_string();
761    }
762    if width == 0 {
763        return String::new();
764    }
765    format!("{}\u{2026}", set_cell_size(text, width - 1))
766}
767
768/// Port of `Table.__rich_console__.render_annotation`: markup and emoji are
769/// enabled, automatic highlighting is disabled, and long annotations wrap.
770fn render_annotation(
771    console: &Console,
772    options: &ConsoleOptions,
773    annotation: &str,
774    style: &str,
775    width: usize,
776) -> Vec<Vec<Segment>> {
777    let expanded = console.expand_emoji(annotation);
778    let mut text = Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded));
779    text.set_base_style(style);
780    let overflow = options.overflow.unwrap_or(Overflow::Fold);
781    let no_wrap = options.no_wrap.unwrap_or(false) || overflow == Overflow::Ignore;
782    let mut lines = Vec::new();
783    for mut hard_line in text.split("\n", false, true) {
784        hard_line.expand_tabs(DEFAULT_TAB_SIZE);
785        let wrapped = if no_wrap {
786            vec![hard_line]
787        } else {
788            let char_offsets: Vec<usize> = hard_line
789                .plain()
790                .char_indices()
791                .map(|(i, _)| i)
792                .chain(std::iter::once(hard_line.plain().len()))
793                .collect();
794            let breaks: Vec<usize> =
795                crate::wrap::divide_line(hard_line.plain(), width, overflow == Overflow::Fold)
796                    .into_iter()
797                    .map(|i| char_offsets[i])
798                    .collect();
799            hard_line.divide(&breaks)
800        };
801        for mut line in wrapped {
802            if overflow != Overflow::Ignore {
803                // Upstream justifies the Text before rendering its segments.
804                // This preserves annotation span boundaries while merging the
805                // base-styled padding with an unstyled title's single run.
806                line.rstrip();
807                line.truncate(width, Some(overflow), false);
808                line.pad_left(width.saturating_sub(line.cell_len()) / 2, ' ');
809                line.pad_right(width.saturating_sub(line.cell_len()), ' ');
810                line.truncate(width, Some(overflow), false);
811            }
812            lines.push(line.render(console.theme(), console.base_style()));
813        }
814    }
815    lines
816}
817
818/// Round half to even (banker's rounding), matching Python's `round`.
819fn round_half_even(value: f64) -> i64 {
820    let floor = value.floor();
821    let diff = value - floor;
822    if (diff - 0.5).abs() < 1e-9 {
823        let f = floor as i64;
824        if f % 2 == 0 {
825            f
826        } else {
827            f + 1
828        }
829    } else {
830        value.round() as i64
831    }
832}
833
834/// Reduce `values` by `total`, distributed across slots by `ratios` (capped by
835/// `maximums`). Direct port of `rich._ratio.ratio_reduce`.
836fn ratio_reduce(total: i64, ratios: &[i64], maximums: &[i64], values: &[i64]) -> Vec<i64> {
837    let ratios: Vec<i64> = ratios
838        .iter()
839        .zip(maximums)
840        .map(|(&r, &m)| if m != 0 { r } else { 0 })
841        .collect();
842    let mut total_ratio: i64 = ratios.iter().sum();
843    if total_ratio == 0 {
844        return values.to_vec();
845    }
846    let mut total_remaining = total;
847    let mut result = Vec::with_capacity(values.len());
848    for ((&ratio, &maximum), &value) in ratios.iter().zip(maximums).zip(values) {
849        if ratio != 0 && total_ratio > 0 {
850            let distributed = maximum.min(round_half_even(
851                ratio as f64 * total_remaining as f64 / total_ratio as f64,
852            ));
853            result.push(value - distributed);
854            total_remaining -= distributed;
855            total_ratio -= ratio;
856        } else {
857            result.push(value);
858        }
859    }
860    result
861}
862
863/// Divide `total` across slots proportionally to `ratios` (ceil each share),
864/// each share floored at the matching `minimums` entry when given. Port of
865/// `rich._ratio.ratio_distribute`.
866fn ratio_distribute(total: i64, ratios: &[i64], minimums: Option<&[i64]>) -> Vec<i64> {
867    // Upstream zeroes the ratio of any slot whose minimum is 0 (falsy).
868    let ratios: Vec<i64> = match minimums {
869        Some(mins) => ratios
870            .iter()
871            .zip(mins)
872            .map(|(&r, &m)| if m != 0 { r } else { 0 })
873            .collect(),
874        None => ratios.to_vec(),
875    };
876    let mut total_ratio: i64 = ratios.iter().sum();
877    let mut total_remaining = total;
878    let mut result = Vec::with_capacity(ratios.len());
879    for (index, &ratio) in ratios.iter().enumerate() {
880        let minimum = minimums.map_or(0, |m| m[index]);
881        let distributed = if total_ratio > 0 {
882            // ceil(ratio * total_remaining / total_ratio) for positive values,
883            // then floored at `minimum`.
884            let numerator = ratio * total_remaining;
885            let ceil_div = (numerator + total_ratio - 1) / total_ratio;
886            minimum.max(ceil_div)
887        } else {
888            total_remaining
889        };
890        result.push(distributed);
891        total_ratio -= ratio;
892        total_remaining -= distributed;
893    }
894    result
895}
896
897/// Reduce `widths` so their total is under `max_width`, shrinking the widest
898/// wrapable columns first. Direct port of `Table._collapse_widths`.
899fn collapse_widths(mut widths: Vec<i64>, wrapable: &[bool], max_width: i64) -> Vec<i64> {
900    let mut total_width: i64 = widths.iter().sum();
901    let mut excess_width = total_width - max_width;
902    if wrapable.iter().any(|&w| w) {
903        while total_width != 0 && excess_width > 0 {
904            let max_column = widths
905                .iter()
906                .zip(wrapable)
907                .filter(|(_, &w)| w)
908                .map(|(&x, _)| x)
909                .max()
910                .unwrap_or(0);
911            let second_max_column = widths
912                .iter()
913                .zip(wrapable)
914                .map(|(&x, &w)| if w && x != max_column { x } else { 0 })
915                .max()
916                .unwrap_or(0);
917            let column_difference = max_column - second_max_column;
918            let ratios: Vec<i64> = widths
919                .iter()
920                .zip(wrapable)
921                .map(|(&x, &w)| i64::from(x == max_column && w))
922                .collect();
923            if !ratios.iter().any(|&r| r != 0) || column_difference == 0 {
924                break;
925            }
926            let max_reduce = vec![excess_width.min(column_difference); widths.len()];
927            widths = ratio_reduce(excess_width, &ratios, &max_reduce, &widths);
928            total_width = widths.iter().sum();
929            excess_width = total_width - max_width;
930        }
931    }
932    widths
933}
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938    use crate::color::ColorSystem;
939    use crate::r#box::SQUARE;
940
941    fn console() -> Console {
942        Console::builder()
943            .force_terminal(true)
944            .color_system(Some(ColorSystem::Truecolor))
945            .width(40)
946            .no_color(false)
947            .build()
948    }
949
950    #[test]
951    fn owned_rows_preserve_measurement_styles_and_missing_cells() {
952        use crate::protocol::OwnedTableRows;
953        for width in [1, 12, 40, 80] {
954            let console = Console::builder().width(width).force_terminal(true).build();
955            let build = || {
956                let mut table = Table::new()
957                    .title("Rows")
958                    .caption("owned or borrowed")
959                    .show_lines(true);
960                table.add_column("Name");
961                table.add_column_justify("Value", Justify::Right);
962                table
963            };
964            let mut borrowed = build();
965            let mut owned = build();
966            for row in [
967                vec!["漢字\n🙂", "123"],
968                vec!["short"],
969                vec!["extra", "4", "ignored"],
970            ] {
971                borrowed.add_row(&row);
972                owned.extend_owned_rows(vec![row.into_iter().map(str::to_owned).collect()]);
973            }
974            assert_eq!(
975                console.render_to_string(&borrowed),
976                console.render_to_string(&owned)
977            );
978        }
979    }
980
981    #[test]
982    fn simple_square_table() {
983        let mut table = Table::new().box_set(SQUARE);
984        table.add_column("Name");
985        table.add_column("Age");
986        table.add_row(&["Alice", "30"]);
987        table.add_row(&["Bob", "7"]);
988        let out = console().render_export(&table);
989        let expected = concat!(
990            "┌───────┬─────┐\n",
991            "│\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",
992            "├───────┼─────┤\n",
993            "│ Alice │ 30  │\n",
994            "│ Bob   │ 7   │\n",
995            "└───────┴─────┘\n",
996        );
997        assert_eq!(out, expected);
998    }
999
1000    #[test]
1001    fn streamed_lines_match_styled_table_output() {
1002        let mut table = Table::new().box_set(SQUARE);
1003        table.add_column("Name");
1004        table.add_column("Age");
1005        table.add_row(&["Alice", "30"]);
1006        table.add_row(&["Bob", "7"]);
1007        let console = console();
1008        let mut streamed = String::new();
1009        table
1010            .try_for_each_line(&console, &console.options(), |line| {
1011                assert!(line.iter().all(|segment| !segment.text.contains('\n')));
1012                streamed.push_str(&console.segments_to_string(&line));
1013                streamed.push('\n');
1014                Ok::<_, std::convert::Infallible>(())
1015            })
1016            .unwrap();
1017        // `simple_square_table` above fixes these bytes independently of the
1018        // collection path, including distinct header-style segments.
1019        assert_eq!(streamed, console.render_export(&table));
1020        assert_eq!(streamed.lines().count(), 6);
1021    }
1022
1023    #[test]
1024    fn streamed_lines_stop_at_the_first_writer_error() {
1025        let mut table = Table::new()
1026            .box_set(SQUARE)
1027            .title("People")
1028            .caption("End")
1029            .show_lines(true);
1030        table.add_column("Name");
1031        table.add_row(&["Alice\nBob"]);
1032        table.add_row(&["Carol"]);
1033        let console = console();
1034        let mut visits = 0;
1035        let result = table.try_for_each_line(&console, &console.options(), |_| {
1036            visits += 1;
1037            if visits == 5 {
1038                Err("writer failed")
1039            } else {
1040                Ok(())
1041            }
1042        });
1043        assert_eq!(result, Err("writer failed"));
1044        assert_eq!(visits, 5);
1045    }
1046
1047    /// A column squeezed below its own padding still emitted a full left and
1048    /// right pad, so each such column spent two cells where its border spent
1049    /// one. The content row then overflowed the table and was cropped, losing
1050    /// its right-hand border while the border rows kept theirs.
1051    #[test]
1052    fn a_column_narrower_than_its_padding_stays_inside_the_border() {
1053        for ncols in [20usize, 29, 40] {
1054            let mut table = Table::new().box_set(SQUARE);
1055            for i in 0..ncols {
1056                table.add_column(format!("c{i}"));
1057            }
1058            let row: Vec<String> = (0..ncols).map(|i| i.to_string()).collect();
1059            table.add_row(&row.iter().map(String::as_str).collect::<Vec<_>>());
1060            let console = Console::builder().width(80).no_color(true).build();
1061            let out = console.render_to_string(&table);
1062            let rows: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
1063            let widths: Vec<usize> = rows.iter().map(|r| r.chars().count()).collect();
1064            assert!(
1065                widths.iter().all(|w| *w == widths[0]),
1066                "{ncols} columns produced ragged rows: {widths:?}"
1067            );
1068            for (index, row) in rows.iter().enumerate() {
1069                let last = row.chars().last().expect("non-empty row");
1070                assert!(
1071                    !last.is_whitespace(),
1072                    "{ncols} columns: row {index} lost its right border: {row:?}"
1073                );
1074            }
1075        }
1076    }
1077
1078    /// A cell spanning several lines occupies its WIDEST line. Measuring the raw
1079    /// string made it as wide as all its lines summed — `\n` measures zero, so
1080    /// nothing capped it — and a quoted CSV cell holding two sentences blew its
1081    /// column out to 31 cells where upstream gives 23.
1082    #[test]
1083    fn a_multi_line_cell_is_measured_by_its_widest_line() {
1084        let mut table = Table::new().box_set(SQUARE);
1085        table.add_column("name");
1086        table.add_column("bio");
1087        table.add_row(&["Alice", "line one\nline two is much longer"]);
1088        table.add_row(&["Bob", "short"]);
1089        let console = Console::builder().width(60).no_color(true).build();
1090        let out = console.render_to_string(&table);
1091        let top = out.lines().next().expect("a top border");
1092        let width = top.chars().count();
1093        // "line two is much longer" is 23 cells; summing both lines would be 31.
1094        assert!(
1095            width < 40,
1096            "the multi-line cell was measured as the sum of its lines: {width} wide"
1097        );
1098        assert!(
1099            out.contains("line two is much longer"),
1100            "content lost: {out:?}"
1101        );
1102    }
1103}