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//! String (markup), `Text` and renderable items, laid out in upstream's
7//! box-less `Table.grid` (collapsed gaps, no edge padding), with every
8//! upstream option: `padding`, `width`, `expand`, `equal`, `column_first`,
9//! `right_to_left`, `align` and `title`.
10
11use std::sync::Arc;
12
13use crate::align::{Align, HorizontalAlign};
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    width: Option<usize>,
28    column_first: bool,
29    right_to_left: bool,
30    align: Option<HorizontalAlign>,
31    title: Option<String>,
32}
33
34impl Columns {
35    /// Columns of string items with the default padding `(0, 1)`. Each string
36    /// is console markup, converted with the console's defaults (markup, emoji
37    /// and highlighting), as upstream's `console.render_str(renderable)` does.
38    pub fn new(items: Vec<String>) -> Self {
39        Columns::from_cells(items.into_iter().map(Cell::Markup).collect())
40    }
41
42    /// Columns of any items: markup strings, literal [`Text`](crate::text::Text)
43    /// or renderables, as upstream's `Columns(renderables)` accepts.
44    pub fn from_cells(items: Vec<Cell>) -> Self {
45        Columns {
46            items,
47            padding: (0, 1, 0, 1),
48            expand: false,
49            equal: false,
50            width: None,
51            column_first: false,
52            right_to_left: false,
53            align: None,
54            title: None,
55        }
56    }
57
58    /// Add an item. Port of `Columns.add_renderable`.
59    pub fn add_renderable(&mut self, item: impl Into<Cell>) -> &mut Self {
60        self.items.push(item.into());
61        self
62    }
63
64    /// Padding around each cell as `(top, right, bottom, left)` (upstream
65    /// `padding`, default `(0, 1)`); the gap between columns is the larger of
66    /// left and right.
67    pub fn padding(mut self, padding: (usize, usize, usize, usize)) -> Self {
68        self.padding = padding;
69        self
70    }
71
72    /// A fixed width for every column (upstream `width`): as many columns as
73    /// `max_width // (width + gap)`.
74    pub fn width(mut self, width: usize) -> Self {
75        self.width = Some(width);
76        self
77    }
78
79    /// Fill columns top to bottom rather than rows left to right (upstream
80    /// `column_first`).
81    pub fn column_first(mut self, column_first: bool) -> Self {
82        self.column_first = column_first;
83        self
84    }
85
86    /// Lay each row out from the right (upstream `right_to_left`).
87    pub fn right_to_left(mut self, right_to_left: bool) -> Self {
88        self.right_to_left = right_to_left;
89        self
90    }
91
92    /// Align every item within its column (upstream `align`).
93    pub fn align(mut self, align: HorizontalAlign) -> Self {
94        self.align = Some(align);
95        self
96    }
97
98    /// A title (console markup) above the columns (upstream `title`).
99    pub fn title(mut self, title: impl Into<String>) -> Self {
100        self.title = Some(title.into());
101        self
102    }
103
104    /// Expand columns to the full width (upstream `expand`).
105    pub fn expand(mut self, expand: bool) -> Self {
106        self.expand = expand;
107        self
108    }
109
110    /// Arrange into equal sized columns (upstream `equal`).
111    pub fn equal(mut self, equal: bool) -> Self {
112        self.equal = equal;
113        self
114    }
115}
116
117/// The item order for `column_count` columns, `None` padding the final row
118/// so it is complete. Port of `iter_renderables`.
119fn iter_order(item_count: usize, column_count: usize, column_first: bool) -> Vec<Option<usize>> {
120    let mut order: Vec<Option<usize>> = if column_first {
121        let mut column_lengths = vec![item_count / column_count; column_count];
122        for length in column_lengths.iter_mut().take(item_count % column_count) {
123            *length += 1;
124        }
125        let row_count = item_count.div_ceil(column_count);
126        let mut cells = vec![vec![None; column_count]; row_count];
127        let (mut row, mut col) = (0, 0);
128        for index in 0..item_count {
129            cells[row][col] = Some(index);
130            column_lengths[col] -= 1;
131            if column_lengths[col] > 0 {
132                row += 1;
133            } else {
134                col += 1;
135                row = 0;
136            }
137        }
138        // `if index == -1: break` at the first gap.
139        cells
140            .into_iter()
141            .flatten()
142            .map_while(|index| index.map(Some))
143            .collect()
144    } else {
145        (0..item_count).map(Some).collect()
146    };
147    let remainder = item_count % column_count;
148    if remainder != 0 {
149        order.resize(order.len() + (column_count - remainder), None);
150    }
151    order
152}
153
154/// Choose the largest column count whose total width fits `max_width`.
155/// Direct port of the width-fitting `while` loop in `Columns.__rich_console__`.
156fn compute_column_count(
157    widths: &[usize],
158    max_width: usize,
159    width_padding: usize,
160    column_first: bool,
161) -> usize {
162    let mut column_count = widths.len();
163    while column_count > 1 {
164        let sequence = iter_order(widths.len(), column_count, column_first)
165            .into_iter()
166            .map(|index| index.map_or(0, |index| widths[index]));
167        let mut columns: Vec<usize> = Vec::new();
168        let mut column_no = 0usize;
169        let mut broke = false;
170        for width in sequence {
171            if column_no == columns.len() {
172                columns.push(width);
173            } else {
174                columns[column_no] = columns[column_no].max(width);
175            }
176            let total: usize =
177                columns.iter().sum::<usize>() + width_padding * columns.len().saturating_sub(1);
178            if total > max_width {
179                column_count = columns.len().saturating_sub(1);
180                broke = true;
181                break;
182            }
183            column_no = (column_no + 1) % column_count;
184        }
185        if !broke {
186            break;
187        }
188    }
189    column_count.max(1)
190}
191
192impl Renderable for Columns {
193    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
194        if self.items.is_empty() {
195            return Vec::new();
196        }
197        let (top, right, bottom, left) = self.padding;
198        let width_padding = left.max(right);
199        // `render_str(renderable) if isinstance(renderable, str)`: strings take
200        // the console's markup, emoji and highlight defaults; a `Text` or
201        // renderable is used as it is.
202        let renderables: Vec<Cell> = self
203            .items
204            .iter()
205            .map(|item| match item {
206                Cell::Markup(markup) => Cell::Text(console.render_str(markup, None)),
207                other => other.clone(),
208            })
209            .collect();
210
211        // `Measurement.get(...).maximum` caps each width at `options.max_width`,
212        // so an item wider than the console still fits in a single column.
213        let mut widths: Vec<usize> = renderables
214            .iter()
215            .map(|cell| cell.measure_cell(console, options).maximum)
216            .collect();
217        if self.equal {
218            let widest = widths.iter().copied().max().unwrap_or(0);
219            widths = vec![widest; widths.len()];
220        }
221
222        let mut table = Table::grid()
223            .padding(top, right, bottom, left)
224            .collapse_padding(true)
225            .pad_edge(false)
226            .expand(self.expand);
227        if let Some(title) = &self.title {
228            table = table.title(title.clone());
229        }
230        let column_count = match self.width {
231            Some(width) => {
232                // Upstream divides by zero columns only to fail later; a
233                // single column is the nearest working layout. A zero width
234                // with no horizontal padding is `ZeroDivisionError` upstream
235                // (the divisor itself is zero); core renders nothing rather
236                // than panic (docs/DIVERGENCES.md).
237                let Some(column_count) = options.max_width.checked_div(width + width_padding)
238                else {
239                    return Vec::new();
240                };
241                let column_count = column_count.max(1);
242                for _ in 0..column_count {
243                    table.add_column("").column_width(width);
244                }
245                column_count
246            }
247            None => {
248                let column_count = compute_column_count(
249                    &widths,
250                    options.max_width,
251                    width_padding,
252                    self.column_first,
253                );
254                for _ in 0..column_count {
255                    table.add_column("");
256                }
257                column_count
258            }
259        };
260
261        // Upstream yields the items in
262        // `Table.grid(padding=self.padding, collapse_padding=True, pad_edge=False)`,
263        // which wraps (or ellipsis-truncates) each item to its column width.
264        // With `equal`, upstream wraps each item in `Constrain(renderable,
265        // renderable_widths[0])`; for text items that is a no-op, since no item
266        // measures wider than the constraint, so only renderables are wrapped.
267        let equal_width = widths.first().copied().unwrap_or(0);
268        let cells: Vec<Cell> = iter_order(renderables.len(), column_count, self.column_first)
269            .into_iter()
270            .map(|index| {
271                // `None` pads the last row: `Table.add_row` renders an empty cell.
272                let Some(index) = index else {
273                    return Cell::Markup(String::new());
274                };
275                let mut cell = renderables[index].clone();
276                if self.equal {
277                    if let Cell::Renderable(renderable) = &cell {
278                        cell = Cell::Renderable(Arc::new(ConstrainCell {
279                            renderable: renderable.clone(),
280                            width: equal_width,
281                        }));
282                    }
283                }
284                if let Some(align) = self.align {
285                    let child: Arc<dyn Renderable + Send + Sync> = match cell {
286                        Cell::Renderable(renderable) => renderable,
287                        Cell::Text(text) => Arc::new(text),
288                        Cell::Markup(markup) => Arc::new(console.render_str(&markup, None)),
289                    };
290                    cell = Cell::Renderable(Arc::new(AlignCell { child, align }));
291                }
292                cell
293            })
294            .collect();
295        for row in cells.chunks(column_count) {
296            let mut row = row.to_vec();
297            if self.right_to_left {
298                row.reverse();
299            }
300            table.add_row_cells(row);
301        }
302        table.rich_render(console, options)
303    }
304}
305
306/// `Align(renderable, align)` around a shared cell renderable (the `align`
307/// path).
308struct AlignCell {
309    child: Arc<dyn Renderable + Send + Sync>,
310    align: HorizontalAlign,
311}
312
313impl Renderable for AlignCell {
314    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
315        Align::render_child(self.child.as_ref(), self.align, console, options)
316    }
317
318    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
319        Measurement::get(console, options, self.child.as_ref())
320    }
321}
322
323/// `Constrain(renderable, width)` around a shared cell renderable (the
324/// `equal` path). Same rules as [`Constrain`](crate::constrain::Constrain).
325struct ConstrainCell {
326    renderable: Arc<dyn Renderable + Send + Sync>,
327    width: usize,
328}
329
330impl Renderable for ConstrainCell {
331    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
332        let options = options.update_width(self.width.min(options.max_width));
333        if options.max_width < 1 {
334            return Vec::new();
335        }
336        self.renderable.rich_render(console, &options)
337    }
338
339    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
340        Measurement::get(
341            console,
342            &options.update_width(self.width),
343            self.renderable.as_ref(),
344        )
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::color::ColorSystem;
352
353    fn console(width: usize) -> Console {
354        Console::builder()
355            .force_terminal(true)
356            .color_system(Some(ColorSystem::Truecolor))
357            .width(width)
358            .build()
359    }
360
361    fn columns(items: &[&str]) -> Columns {
362        Columns::new(items.iter().map(|s| s.to_string()).collect())
363    }
364
365    #[test]
366    fn packs_into_two_rows() {
367        let out =
368            console(20).render_export(&columns(&["one", "two", "three", "four", "five", "six"]));
369        assert_eq!(out, "one  two three four\nfive six           \n");
370    }
371
372    #[test]
373    fn single_row_when_it_fits() {
374        let out = console(30).render_export(&columns(&["alpha", "beta", "gamma", "delta"]));
375        assert_eq!(out, "alpha beta gamma delta\n");
376    }
377
378    #[test]
379    fn truncates_an_item_wider_than_the_width() {
380        let out = console(8).render_export(&columns(&["supercalifragilistic"]));
381        assert_eq!(out, "superca…\n");
382    }
383
384    #[test]
385    fn wraps_an_item_wider_than_the_width() {
386        let out = console(13).render_export(&columns(&["name name name"]));
387        assert_eq!(out, "name name    \nname         \n");
388    }
389}