Skip to main content

rich/
columns.rs

1//! Columns — arrange renderables in a grid.
2//!
3//! Port of upstream `rich/columns.py`. [`Columns`] packs items into as many
4//! equal-gap columns as fit the available width, filling row by row.
5//!
6//! Slice scope: string (markup), `Text` and renderable items with the default
7//! padding `(0, 1)`, laid out in upstream's box-less `Table.grid` (collapsed
8//! single-space gaps, no edge padding), plus `equal` and `expand`.
9//! `width`/`column_first`/`right_to_left`/`align`/`title` are deferred with
10//! the rest of `columns.py`.
11
12use std::sync::Arc;
13
14use crate::console::{Console, ConsoleOptions};
15use crate::measure::Measurement;
16use crate::protocol::Renderable;
17use crate::segment::Segment;
18use crate::table::{Cell, Table};
19
20/// Arranges items into a grid of columns. Mirrors `rich.columns.Columns`.
21pub struct Columns {
22    items: Vec<Cell>,
23    /// `(top, right, bottom, left)`; only left/right are used for gap sizing.
24    padding: (usize, usize, usize, usize),
25    expand: bool,
26    equal: bool,
27}
28
29impl Columns {
30    /// Columns of string items with the default padding `(0, 1)`. Each string
31    /// is console markup, converted with the console's defaults (markup, emoji
32    /// and highlighting), as upstream's `console.render_str(renderable)` does.
33    pub fn new(items: Vec<String>) -> Self {
34        Columns::from_cells(items.into_iter().map(Cell::Markup).collect())
35    }
36
37    /// Columns of any items: markup strings, literal [`Text`](crate::text::Text)
38    /// or renderables, as upstream's `Columns(renderables)` accepts.
39    pub fn from_cells(items: Vec<Cell>) -> Self {
40        Columns {
41            items,
42            padding: (0, 1, 0, 1),
43            expand: false,
44            equal: false,
45        }
46    }
47
48    /// Expand columns to the full width (upstream `expand`).
49    pub fn expand(mut self, expand: bool) -> Self {
50        self.expand = expand;
51        self
52    }
53
54    /// Arrange into equal sized columns (upstream `equal`).
55    pub fn equal(mut self, equal: bool) -> Self {
56        self.equal = equal;
57        self
58    }
59}
60
61/// The width sequence for `column_count`: item widths, then zero-padded so the
62/// final row is complete. Port of the non-`column_first` branch of
63/// `iter_renderables`.
64fn iter_widths(widths: &[usize], column_count: usize) -> Vec<usize> {
65    let mut sequence = widths.to_vec();
66    let remainder = widths.len() % column_count;
67    if remainder != 0 {
68        sequence.resize(widths.len() + (column_count - remainder), 0);
69    }
70    sequence
71}
72
73/// Choose the largest column count whose total width fits `max_width`.
74/// Direct port of the width-fitting `while` loop in `Columns.__rich_console__`.
75fn compute_column_count(widths: &[usize], max_width: usize, width_padding: usize) -> usize {
76    let mut column_count = widths.len();
77    while column_count > 1 {
78        let sequence = iter_widths(widths, column_count);
79        let mut columns: Vec<usize> = Vec::new();
80        let mut column_no = 0usize;
81        let mut broke = false;
82        for width in sequence {
83            if column_no == columns.len() {
84                columns.push(width);
85            } else {
86                columns[column_no] = columns[column_no].max(width);
87            }
88            let total: usize =
89                columns.iter().sum::<usize>() + width_padding * columns.len().saturating_sub(1);
90            if total > max_width {
91                column_count = columns.len().saturating_sub(1);
92                broke = true;
93                break;
94            }
95            column_no = (column_no + 1) % column_count;
96        }
97        if !broke {
98            break;
99        }
100    }
101    column_count.max(1)
102}
103
104impl Renderable for Columns {
105    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
106        if self.items.is_empty() {
107            return Vec::new();
108        }
109        let (top, right, bottom, left) = self.padding;
110        let width_padding = left.max(right);
111        // `render_str(renderable) if isinstance(renderable, str)`: strings take
112        // the console's markup, emoji and highlight defaults; a `Text` or
113        // renderable is used as it is.
114        let renderables: Vec<Cell> = self
115            .items
116            .iter()
117            .map(|item| match item {
118                Cell::Markup(markup) => Cell::Text(console.render_str(markup, None)),
119                other => other.clone(),
120            })
121            .collect();
122
123        // `Measurement.get(...).maximum` caps each width at `options.max_width`,
124        // so an item wider than the console still fits in a single column.
125        let mut widths: Vec<usize> = renderables
126            .iter()
127            .map(|cell| cell.measure_cell(console, options).maximum)
128            .collect();
129        if self.equal {
130            let widest = widths.iter().copied().max().unwrap_or(0);
131            widths = vec![widest; widths.len()];
132        }
133
134        let column_count = compute_column_count(&widths, options.max_width, width_padding);
135
136        // Upstream yields the items in
137        // `Table.grid(padding=self.padding, collapse_padding=True, pad_edge=False)`,
138        // which wraps (or ellipsis-truncates) each item to its column width.
139        // With `equal`, upstream wraps each item in `Constrain(renderable,
140        // renderable_widths[0])`; for text items that is a no-op, since no item
141        // measures wider than the constraint, so only renderables are wrapped.
142        let mut table = Table::grid()
143            .padding(top, right, bottom, left)
144            .collapse_padding(true)
145            .pad_edge(false)
146            .expand(self.expand);
147        for _ in 0..column_count {
148            table.add_column("");
149        }
150        let mut cells = renderables;
151        if self.equal {
152            let width = widths.first().copied().unwrap_or(0);
153            for cell in &mut cells {
154                if let Cell::Renderable(renderable) = cell {
155                    *cell = Cell::Renderable(Arc::new(ConstrainCell {
156                        renderable: renderable.clone(),
157                        width,
158                    }));
159                }
160            }
161        }
162        let remainder = cells.len() % column_count;
163        if remainder != 0 {
164            // `iter_renderables` pads the last row with `None`, which
165            // `Table.add_row` renders as an empty cell.
166            cells.resize(
167                cells.len() + (column_count - remainder),
168                Cell::Markup(String::new()),
169            );
170        }
171        for row in cells.chunks(column_count) {
172            table.add_row_cells(row.to_vec());
173        }
174        table.rich_render(console, options)
175    }
176}
177
178/// `Constrain(renderable, width)` around a shared cell renderable (the
179/// `equal` path). Same rules as [`Constrain`](crate::constrain::Constrain).
180struct ConstrainCell {
181    renderable: Arc<dyn Renderable + Send + Sync>,
182    width: usize,
183}
184
185impl Renderable for ConstrainCell {
186    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
187        let options = options.update_width(self.width.min(options.max_width));
188        if options.max_width < 1 {
189            return Vec::new();
190        }
191        self.renderable.rich_render(console, &options)
192    }
193
194    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
195        Measurement::get(
196            console,
197            &options.update_width(self.width),
198            self.renderable.as_ref(),
199        )
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::color::ColorSystem;
207
208    fn console(width: usize) -> Console {
209        Console::builder()
210            .force_terminal(true)
211            .color_system(Some(ColorSystem::Truecolor))
212            .width(width)
213            .build()
214    }
215
216    fn columns(items: &[&str]) -> Columns {
217        Columns::new(items.iter().map(|s| s.to_string()).collect())
218    }
219
220    #[test]
221    fn packs_into_two_rows() {
222        let out =
223            console(20).render_export(&columns(&["one", "two", "three", "four", "five", "six"]));
224        assert_eq!(out, "one  two three four\nfive six           \n");
225    }
226
227    #[test]
228    fn single_row_when_it_fits() {
229        let out = console(30).render_export(&columns(&["alpha", "beta", "gamma", "delta"]));
230        assert_eq!(out, "alpha beta gamma delta\n");
231    }
232
233    #[test]
234    fn truncates_an_item_wider_than_the_width() {
235        let out = console(8).render_export(&columns(&["supercalifragilistic"]));
236        assert_eq!(out, "superca…\n");
237    }
238
239    #[test]
240    fn wraps_an_item_wider_than_the_width() {
241        let out = console(13).render_export(&columns(&["name name name"]));
242        assert_eq!(out, "name name    \nname         \n");
243    }
244}