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    fn max_content_widths(&self) -> Vec<usize> {
300        let mut widths = vec![0usize; self.columns.len()];
301        for (index, column) in self.columns.iter().enumerate() {
302            if self.show_header {
303                widths[index] = cell_len(&column.header);
304            }
305        }
306        for row in &self.rows {
307            for (index, cell) in row.iter().enumerate() {
308                if index < widths.len() {
309                    widths[index] = widths[index].max(cell_len(cell));
310                }
311            }
312        }
313        widths
314    }
315
316    /// The rendered width (content + padding) of each column, shrinking the
317    /// widest columns to fit `available` when necessary. Port of the non-flexible
318    /// path of `Table._calculate_column_widths` + `_collapse_widths`.
319    fn column_widths(&self, available: usize) -> Vec<usize> {
320        let ncols = self.columns.len();
321        // A fixed-width column uses its declared width; others measure content,
322        // clamped to the column's [min_width, max_width]. Port of `_measure_column`.
323        let content = self.max_content_widths();
324        let mut widths: Vec<i64> = self
325            .columns
326            .iter()
327            .zip(&content)
328            .enumerate()
329            .map(|(index, (column, &measured))| {
330                let (pl, pr) = self.cell_padding(index, ncols);
331                let content_width = match column.width {
332                    Some(w) => w,
333                    None => {
334                        let mut w = measured;
335                        if let Some(min) = column.min_width {
336                            w = w.max(min);
337                        }
338                        if let Some(max) = column.max_width {
339                            w = w.min(max);
340                        }
341                        w
342                    }
343                };
344                (content_width + pl + pr) as i64
345            })
346            .collect();
347
348        // Expand with explicit ratios: flexible (ratio) columns share the free
349        // width in proportion, fixed columns keep their measured width. Port of
350        // the `if self.expand: … if any(ratios)` block of `_calculate_column_widths`.
351        if self.expand {
352            let ratios: Vec<i64> = self
353                .columns
354                .iter()
355                .filter(|c| c.ratio.is_some())
356                .map(|c| c.ratio.unwrap() as i64)
357                .collect();
358            if ratios.iter().any(|&r| r > 0) {
359                let fixed_widths: Vec<i64> = widths
360                    .iter()
361                    .zip(&self.columns)
362                    .map(|(&w, c)| if c.ratio.is_some() { 0 } else { w })
363                    .collect();
364                let flex_minimum: Vec<i64> = self
365                    .columns
366                    .iter()
367                    .enumerate()
368                    .filter(|(_, c)| c.ratio.is_some())
369                    .map(|(index, c)| {
370                        let (pl, pr) = self.cell_padding(index, ncols);
371                        (c.width.unwrap_or(1) + pl + pr) as i64
372                    })
373                    .collect();
374                let flexible_width = available as i64 - fixed_widths.iter().sum::<i64>();
375                let flex_widths = ratio_distribute(flexible_width, &ratios, Some(&flex_minimum));
376                let mut iter_flex = flex_widths.into_iter();
377                for (index, column) in self.columns.iter().enumerate() {
378                    if column.ratio.is_some() {
379                        widths[index] = fixed_widths[index] + iter_flex.next().unwrap_or(0);
380                    }
381                }
382            }
383        }
384
385        let table_width: i64 = widths.iter().sum();
386        if table_width > available as i64 {
387            // Only auto-width, wrapping columns may shrink; fixed and no_wrap
388            // columns hold their width (no_wrap only yields via the last resort).
389            let wrapable: Vec<bool> = self
390                .columns
391                .iter()
392                .map(|c| c.width.is_none() && !c.no_wrap)
393                .collect();
394            widths = collapse_widths(widths, &wrapable, available as i64);
395            // Last resort: if fixed columns still overflow, reduce every column
396            // evenly. Port of `_calculate_column_widths`'s final `ratio_reduce`.
397            let table_width: i64 = widths.iter().sum();
398            if table_width > available as i64 {
399                let excess = table_width - available as i64;
400                let ratios = vec![1i64; widths.len()];
401                widths = ratio_reduce(excess, &ratios, &widths, &widths);
402            }
403        }
404
405        // Expand: distribute the leftover width proportionally. Port of the
406        // `expand` tail of `_calculate_column_widths` (via `ratio_distribute`).
407        let table_width: i64 = widths.iter().sum();
408        if self.expand && table_width < available as i64 && table_width > 0 {
409            let pad = ratio_distribute(available as i64 - table_width, &widths, None);
410            for (width, extra) in widths.iter_mut().zip(pad) {
411                *width += extra;
412            }
413        }
414        widths.into_iter().map(|w| w.max(0) as usize).collect()
415    }
416
417    /// The effective style for a cell in column `index`: the header style for a
418    /// header row, else that column's own style.
419    fn cell_style(&self, index: usize, is_header: bool) -> Style {
420        if is_header {
421            // A per-column header cell style is combined over the table-level one.
422            match self.columns.get(index).and_then(|c| c.header_fill.as_ref()) {
423                Some(fill) => self.header_style.combine(fill),
424                None => self.header_style.clone(),
425            }
426        } else {
427            self.columns
428                .get(index)
429                .map(|c| c.style.clone())
430                .unwrap_or_default()
431        }
432    }
433
434    /// Render one table row (a list of cell strings) into visual lines.
435    fn render_row(
436        &self,
437        theme: &Theme,
438        cells: &[String],
439        content_widths: &[usize],
440        is_header: bool,
441        edges: (char, char, char),
442    ) -> Vec<Vec<Segment>> {
443        // Horizontal padding is per-column (see `cell_padding`); only the
444        // top/bottom vertical padding is uniform.
445        let (pt, _, pb, _) = self.padding;
446        let (edge_left, edge_vertical, edge_right) = edges;
447        let border = Some(self.style.combine(&self.border_style));
448        let ncols = self.columns.len();
449
450        // Render each cell into padded, simplified visual lines.
451        let mut cell_lines: Vec<Vec<Vec<Segment>>> = Vec::with_capacity(ncols);
452        let mut height = 1;
453        for (index, width) in content_widths.iter().enumerate() {
454            let style = self.cell_style(index, is_header);
455            let cell_fill = Some(style.clone());
456            let content = cells.get(index).map(String::as_str).unwrap_or("");
457            let column = self.columns.get(index);
458            let justify = column.map(|c| c.justify).unwrap_or(Justify::Left);
459            let no_wrap = column.map(|c| c.no_wrap).unwrap_or(false);
460            // A no_wrap cell is one ellipsis-cropped line; otherwise wrap with
461            // ellipsis overflow (the table default). Then justify + pad.
462            let wrapped = if no_wrap {
463                ellipsis_crop(content, *width)
464            } else {
465                wrap_cell(content, *width).join("\n")
466            };
467            let mut text = Text::new(wrapped).justify(justify);
468            // Header content carries its own style span over `header_style`; the
469            // justify/edge padding stays `header_style` (matches upstream).
470            if is_header {
471                if let Some(span) = column.and_then(|c| c.header_content_style.clone()) {
472                    let len = text.plain().len();
473                    text.stylize(span, 0, len);
474                }
475            }
476            let mut lines = text.render_lines(theme, &style, Some(*width));
477            if lines.is_empty() {
478                lines.push(Vec::new());
479            }
480            // Vertical padding (blank content lines top/bottom).
481            let blank = || Segment::new(" ".repeat(*width), cell_fill.clone());
482            let mut padded_lines: Vec<Vec<Segment>> = Vec::new();
483            for _ in 0..pt {
484                padded_lines.push(vec![blank()]);
485            }
486            for line in &lines {
487                let padded = Segment::adjust_line_length(line, *width, cell_fill.clone());
488                padded_lines.push(Segment::simplify(&padded));
489            }
490            for _ in 0..pb {
491                padded_lines.push(vec![blank()]);
492            }
493            height = height.max(padded_lines.len());
494            cell_lines.push(padded_lines);
495        }
496
497        // Pad every column to the row height with blank lines.
498        for (index, lines) in cell_lines.iter_mut().enumerate() {
499            let fill = Some(self.cell_style(index, is_header));
500            while lines.len() < height {
501                lines.push(vec![Segment::new(
502                    " ".repeat(content_widths[index]),
503                    fill.clone(),
504                )]);
505            }
506        }
507
508        let last = ncols.saturating_sub(1);
509        let mut rows_out: Vec<Vec<Segment>> = Vec::with_capacity(height);
510        // `r` indexes into each column's per-line vector, so a range loop is the
511        // natural shape here (the columns are iterated with `enumerate`).
512        #[allow(clippy::needless_range_loop)]
513        for r in 0..height {
514            let mut row = Vec::new();
515            if self.show_edge {
516                row.push(Segment::new(edge_left.to_string(), border.clone()));
517            }
518            for (c, column_lines) in cell_lines.iter().enumerate() {
519                let fill = Some(self.cell_style(c, is_header));
520                let (cpl, cpr) = self.cell_padding(c, ncols);
521                if cpl > 0 {
522                    row.push(Segment::new(" ".repeat(cpl), fill.clone()));
523                }
524                row.extend(column_lines[r].clone());
525                if cpr > 0 {
526                    row.push(Segment::new(" ".repeat(cpr), fill.clone()));
527                }
528                if c != last {
529                    row.push(Segment::new(edge_vertical.to_string(), border.clone()));
530                } else if self.show_edge {
531                    row.push(Segment::new(edge_right.to_string(), border.clone()));
532                }
533            }
534            rows_out.push(row);
535        }
536        rows_out
537    }
538}
539
540impl Renderable for Table {
541    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
542        if self.columns.is_empty() {
543            return Vec::new();
544        }
545        // Fall back to a terminal-safe box on legacy Windows / non-UTF-8.
546        let box_set = self.box_set.substitute(
547            console.legacy_windows(),
548            console.safe_box(),
549            console.ascii_only(),
550        );
551        let ncols = self.columns.len();
552        // Borders occupy: (ncols-1) dividers, plus 2 outer edges when shown.
553        // Port of `_extra_width`.
554        let extra_width = (if self.show_edge { 2 } else { 0 }) + ncols.saturating_sub(1);
555        let available = options.max_width.saturating_sub(extra_width);
556
557        let rendered_widths = self.column_widths(available);
558        let content_widths: Vec<usize> = rendered_widths
559            .iter()
560            .enumerate()
561            .map(|(index, w)| {
562                let (pl, pr) = self.cell_padding(index, ncols);
563                w.saturating_sub(pl + pr)
564            })
565            .collect();
566        let border = Some(self.style.combine(&self.border_style));
567
568        // Full table width (for centering title/caption): columns + borders.
569        let table_width: usize = rendered_widths.iter().sum::<usize>() + extra_width;
570
571        let mut lines: Vec<Vec<Segment>> = Vec::new();
572
573        // Title, centered above the table.
574        if let Some(title) = &self.title {
575            let style = Style::parse("italic").expect("valid built-in style");
576            lines.push(vec![Segment::new(center(title, table_width), Some(style))]);
577        }
578
579        let edge = self.show_edge;
580        if edge {
581            lines.push(vec![Segment::new(
582                box_set.get_top(&rendered_widths, edge),
583                border.clone(),
584            )]);
585        }
586
587        let head_edges = (box_set.head_left, box_set.head_vertical, box_set.head_right);
588        let body_edges = (box_set.mid_left, box_set.mid_vertical, box_set.mid_right);
589
590        if self.show_header {
591            let headers: Vec<String> = self.columns.iter().map(|c| c.header.clone()).collect();
592            lines.extend(self.render_row(
593                console.theme(),
594                &headers,
595                &content_widths,
596                true,
597                head_edges,
598            ));
599            lines.push(vec![Segment::new(
600                box_set.get_row(&rendered_widths, RowLevel::Head, edge),
601                border.clone(),
602            )]);
603        }
604
605        let row_last = self.rows.len().saturating_sub(1);
606        for (index, row) in self.rows.iter().enumerate() {
607            lines.extend(self.render_row(console.theme(), row, &content_widths, false, body_edges));
608            if self.show_lines && index != row_last {
609                lines.push(vec![Segment::new(
610                    box_set.get_row(&rendered_widths, RowLevel::Row, edge),
611                    border.clone(),
612                )]);
613            }
614        }
615
616        if edge {
617            lines.push(vec![Segment::new(
618                box_set.get_bottom(&rendered_widths, edge),
619                border.clone(),
620            )]);
621        }
622
623        // Caption, centered below the table.
624        if let Some(caption) = &self.caption {
625            let style = Style::parse("dim italic").expect("valid built-in style");
626            lines.push(vec![Segment::new(
627                center(caption, table_width),
628                Some(style),
629            )]);
630        }
631
632        // Join visual lines with newline segments (no trailing newline).
633        let mut segments = Vec::new();
634        let last = lines.len().saturating_sub(1);
635        for (index, line) in lines.into_iter().enumerate() {
636            segments.extend(line);
637            if index != last {
638                segments.push(Segment::line());
639            }
640        }
641        segments
642    }
643}
644
645/// Wrap `content` to `width` cells with **ellipsis overflow** (the table
646/// default): words are broken between, and a single word wider than `width` is
647/// cropped with a trailing `…`. Returns one string per visual line.
648fn wrap_cell(content: &str, width: usize) -> Vec<String> {
649    if width == 0 {
650        return vec![String::new()];
651    }
652    // `fold = false`: over-long words stay on their own (overflowing) line,
653    // which `ellipsis_crop` then trims — matching `Text(overflow="ellipsis")`.
654    let breaks = crate::wrap::divide_line(content, width, false);
655    let chars: Vec<char> = content.chars().collect();
656    let mut lines: Vec<String> = Vec::new();
657    let mut start = 0;
658    for stop in breaks {
659        lines.push(chars[start..stop].iter().collect());
660        start = stop;
661    }
662    lines.push(chars[start..].iter().collect());
663    // Trailing whitespace is dropped before the overflow check, so a word that
664    // fills the width exactly isn't spuriously ellipsized by its trailing space.
665    lines
666        .iter()
667        .map(|line| ellipsis_crop(line.trim_end(), width))
668        .collect()
669}
670
671/// Crop `text` to `width` cells, replacing the trailing cell with `…` when it
672/// doesn't fit. Port of the `overflow="ellipsis"` path of `Text.truncate`.
673fn ellipsis_crop(text: &str, width: usize) -> String {
674    if cell_len(text) <= width {
675        return text.to_string();
676    }
677    if width == 0 {
678        return String::new();
679    }
680    format!("{}\u{2026}", set_cell_size(text, width - 1))
681}
682
683/// Center `text` within `width` cells (floor-left), padding with spaces.
684fn center(text: &str, width: usize) -> String {
685    let excess = width.saturating_sub(cell_len(text));
686    let left = excess / 2;
687    let right = excess - left;
688    format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
689}
690
691/// Round half to even (banker's rounding), matching Python's `round`.
692fn round_half_even(value: f64) -> i64 {
693    let floor = value.floor();
694    let diff = value - floor;
695    if (diff - 0.5).abs() < 1e-9 {
696        let f = floor as i64;
697        if f % 2 == 0 {
698            f
699        } else {
700            f + 1
701        }
702    } else {
703        value.round() as i64
704    }
705}
706
707/// Reduce `values` by `total`, distributed across slots by `ratios` (capped by
708/// `maximums`). Direct port of `rich._ratio.ratio_reduce`.
709fn ratio_reduce(total: i64, ratios: &[i64], maximums: &[i64], values: &[i64]) -> Vec<i64> {
710    let ratios: Vec<i64> = ratios
711        .iter()
712        .zip(maximums)
713        .map(|(&r, &m)| if m != 0 { r } else { 0 })
714        .collect();
715    let mut total_ratio: i64 = ratios.iter().sum();
716    if total_ratio == 0 {
717        return values.to_vec();
718    }
719    let mut total_remaining = total;
720    let mut result = Vec::with_capacity(values.len());
721    for ((&ratio, &maximum), &value) in ratios.iter().zip(maximums).zip(values) {
722        if ratio != 0 && total_ratio > 0 {
723            let distributed = maximum.min(round_half_even(
724                ratio as f64 * total_remaining as f64 / total_ratio as f64,
725            ));
726            result.push(value - distributed);
727            total_remaining -= distributed;
728            total_ratio -= ratio;
729        } else {
730            result.push(value);
731        }
732    }
733    result
734}
735
736/// Divide `total` across slots proportionally to `ratios` (ceil each share),
737/// each share floored at the matching `minimums` entry when given. Port of
738/// `rich._ratio.ratio_distribute`.
739fn ratio_distribute(total: i64, ratios: &[i64], minimums: Option<&[i64]>) -> Vec<i64> {
740    // Upstream zeroes the ratio of any slot whose minimum is 0 (falsy).
741    let ratios: Vec<i64> = match minimums {
742        Some(mins) => ratios
743            .iter()
744            .zip(mins)
745            .map(|(&r, &m)| if m != 0 { r } else { 0 })
746            .collect(),
747        None => ratios.to_vec(),
748    };
749    let mut total_ratio: i64 = ratios.iter().sum();
750    let mut total_remaining = total;
751    let mut result = Vec::with_capacity(ratios.len());
752    for (index, &ratio) in ratios.iter().enumerate() {
753        let minimum = minimums.map_or(0, |m| m[index]);
754        let distributed = if total_ratio > 0 {
755            // ceil(ratio * total_remaining / total_ratio) for positive values,
756            // then floored at `minimum`.
757            let numerator = ratio * total_remaining;
758            let ceil_div = (numerator + total_ratio - 1) / total_ratio;
759            minimum.max(ceil_div)
760        } else {
761            total_remaining
762        };
763        result.push(distributed);
764        total_ratio -= ratio;
765        total_remaining -= distributed;
766    }
767    result
768}
769
770/// Reduce `widths` so their total is under `max_width`, shrinking the widest
771/// wrapable columns first. Direct port of `Table._collapse_widths`.
772fn collapse_widths(mut widths: Vec<i64>, wrapable: &[bool], max_width: i64) -> Vec<i64> {
773    let mut total_width: i64 = widths.iter().sum();
774    let mut excess_width = total_width - max_width;
775    if wrapable.iter().any(|&w| w) {
776        while total_width != 0 && excess_width > 0 {
777            let max_column = widths
778                .iter()
779                .zip(wrapable)
780                .filter(|(_, &w)| w)
781                .map(|(&x, _)| x)
782                .max()
783                .unwrap_or(0);
784            let second_max_column = widths
785                .iter()
786                .zip(wrapable)
787                .map(|(&x, &w)| if w && x != max_column { x } else { 0 })
788                .max()
789                .unwrap_or(0);
790            let column_difference = max_column - second_max_column;
791            let ratios: Vec<i64> = widths
792                .iter()
793                .zip(wrapable)
794                .map(|(&x, &w)| i64::from(x == max_column && w))
795                .collect();
796            if !ratios.iter().any(|&r| r != 0) || column_difference == 0 {
797                break;
798            }
799            let max_reduce = vec![excess_width.min(column_difference); widths.len()];
800            widths = ratio_reduce(excess_width, &ratios, &max_reduce, &widths);
801            total_width = widths.iter().sum();
802            excess_width = total_width - max_width;
803        }
804    }
805    widths
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811    use crate::color::ColorSystem;
812    use crate::r#box::SQUARE;
813
814    fn console() -> Console {
815        Console::builder()
816            .force_terminal(true)
817            .color_system(Some(ColorSystem::Truecolor))
818            .width(40)
819            .no_color(false)
820            .build()
821    }
822
823    #[test]
824    fn simple_square_table() {
825        let mut table = Table::new().box_set(SQUARE);
826        table.add_column("Name");
827        table.add_column("Age");
828        table.add_row(&["Alice", "30"]);
829        table.add_row(&["Bob", "7"]);
830        let out = console().render_export(&table);
831        let expected = concat!(
832            "┌───────┬─────┐\n",
833            "│\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",
834            "├───────┼─────┤\n",
835            "│ Alice │ 30  │\n",
836            "│ Bob   │ 7   │\n",
837            "└───────┴─────┘\n",
838        );
839        assert_eq!(out, expected);
840    }
841}