Skip to main content

rich_ext/table/
data.rs

1//! Plain rows with a sort, a grouping and totals, rendered as a core table.
2
3use rich::table::Cell;
4use rich::{Console, ConsoleOptions, Renderable, Segment, Table, Text};
5
6use super::group::{Aggregate, GroupBy};
7use super::rules::{ResolvedRules, StyleRules};
8use super::sort::{sorted_indices, SortKey};
9use super::{frame_builders, headers, style, Column, Frame, Value};
10
11/// Rows of [`Value`]s under [`Column`]s, optionally sorted, grouped and
12/// totalled, rendered as one core [`Table`].
13///
14/// Sorting is stable and puts empty cells last ([`sort`](super::sort)); the
15/// sorted columns get a `▲`/`▼` header indicator (`^`/`v` on an ASCII-only
16/// console). With a [`GroupBy`], each group renders as a header row with the
17/// group's key in the first column (prefixed by the grouped column's header
18/// when that is not the first column), the group's rows, and — when the
19/// grouping has aggregates — a summary row. [`totals`](TableData::totals) adds
20/// a final summary over every row.
21///
22/// ```
23/// use rich::{Console, Justify};
24/// use rich_ext::table::{Aggregate, Column, GroupBy, SortKey, TableData, Value};
25///
26/// let mut data = TableData::new([
27///     Column::new("team"),
28///     Column::new("service"),
29///     Column::new("pods").justify(Justify::Right),
30/// ]);
31/// data.push(["core".into(), "api".into(), Value::Int(3)]);
32/// data.push(["edge".into(), "web".into(), Value::Int(5)]);
33/// data.push(["core".into(), "db".into(), Value::Int(1)]);
34/// let data = data
35///     .sort_by([SortKey::asc(0), SortKey::asc(1)])
36///     .group_by(GroupBy::new(0).aggregate(Aggregate::sum(2)));
37///
38/// let out = Console::builder().width(40).build().render_export(&data);
39/// assert_eq!(
40///     out,
41///     "\
42/// ┏━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━┓
43/// ┃ team ▲1  ┃ service ▲2 ┃ pods ┃
44/// ┡━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━┩
45/// │ core     │            │      │
46/// │ core     │ api        │    3 │
47/// │ core     │ db         │    1 │
48/// │ subtotal │            │    4 │
49/// │ edge     │            │      │
50/// │ edge     │ web        │    5 │
51/// │ subtotal │            │    5 │
52/// └──────────┴────────────┴──────┘
53/// "
54/// );
55/// ```
56#[derive(Clone, Debug)]
57pub struct TableData {
58    columns: Vec<Column>,
59    rows: Vec<Vec<Value>>,
60    sort: Vec<SortKey>,
61    group: Option<GroupBy>,
62    totals: Vec<Aggregate>,
63    totals_label: String,
64    rules: StyleRules,
65    pub(super) frame: Frame,
66}
67
68frame_builders!([] TableData);
69
70impl TableData {
71    /// No rows under `columns`.
72    pub fn new(columns: impl IntoIterator<Item = Column>) -> Self {
73        TableData {
74            columns: columns.into_iter().collect(),
75            rows: Vec::new(),
76            sort: Vec::new(),
77            group: None,
78            totals: Vec::new(),
79            totals_label: "total".to_string(),
80            rules: StyleRules::new(),
81            frame: Frame::default(),
82        }
83    }
84
85    /// Append a row. Missing cells are `Null`; extra cells are dropped.
86    pub fn push(&mut self, row: impl IntoIterator<Item = Value>) -> &mut Self {
87        let row = normalize(row, self.columns.len());
88        self.rows.push(row);
89        self
90    }
91
92    /// Append several rows.
93    pub fn extend<R: IntoIterator<Item = Value>>(
94        &mut self,
95        rows: impl IntoIterator<Item = R>,
96    ) -> &mut Self {
97        for row in rows {
98            self.push(row);
99        }
100        self
101    }
102
103    /// Sort by `keys` when rendering (the rows keep their stored order).
104    pub fn sort_by(mut self, keys: impl IntoIterator<Item = SortKey>) -> Self {
105        self.set_sort(keys);
106        self
107    }
108
109    /// Replace the sort keys; an empty list keeps insertion order.
110    pub fn set_sort(&mut self, keys: impl IntoIterator<Item = SortKey>) {
111        self.sort = keys.into_iter().collect();
112    }
113
114    /// Group the (sorted) rows.
115    pub fn group_by(mut self, group: GroupBy) -> Self {
116        self.group = Some(group);
117        self
118    }
119
120    /// Add a final summary row over every row. `label` fills the first cell,
121    /// followed by the first column's own aggregate if it has one
122    /// (`total: 6`); an empty label leaves the aggregate alone.
123    pub fn totals(
124        mut self,
125        label: impl Into<String>,
126        aggregates: impl IntoIterator<Item = Aggregate>,
127    ) -> Self {
128        self.totals_label = label.into();
129        self.totals = aggregates.into_iter().collect();
130        self
131    }
132
133    /// Style cells, rows and columns by value ([`rules`](super::rules)).
134    /// Rules restyle the rows; group headers and summary rows keep their
135    /// own styles.
136    pub fn style_rules(mut self, rules: StyleRules) -> Self {
137        self.rules = rules;
138        self
139    }
140
141    /// The columns.
142    pub fn columns(&self) -> &[Column] {
143        &self.columns
144    }
145
146    /// The rows, in the order they were added.
147    pub fn rows(&self) -> &[Vec<Value>] {
148        &self.rows
149    }
150
151    /// The sort keys.
152    pub fn sort_keys(&self) -> &[SortKey] {
153        &self.sort
154    }
155
156    /// Row indices in display order (stable sort by the keys).
157    pub fn order(&self) -> Vec<usize> {
158        sorted_indices(&self.rows, &self.sort)
159    }
160
161    /// The core table: headers with sort indicators, then the sorted rows,
162    /// grouped and totalled as configured.
163    pub fn to_table(&self, console: &Console) -> Table {
164        let headers = headers(console, &self.columns, &self.sort);
165        let mut table = self.frame.table(&self.columns, &headers, true, true);
166        let order = self.order();
167        let headers: Vec<&str> = self.columns.iter().map(Column::header).collect();
168        let rules = (!self.rules.is_empty()).then(|| self.rules.resolve(&headers, &self.rows));
169        let add = |table: &mut Table, row: &[Value]| match &rules {
170            None => {
171                table.add_row_text(self.cells(row));
172            }
173            Some(rules) => self.add_styled(table, rules, row),
174        };
175        match &self.group {
176            None => {
177                for &row in &order {
178                    add(&mut table, &self.rows[row]);
179                }
180            }
181            Some(group) => {
182                let group_style = style(console, "table.group");
183                for g in group.groups(&self.rows, &order) {
184                    let mut label = if g.key.is_empty() {
185                        Text::new("(empty)")
186                    } else {
187                        self.columns
188                            .get(group.column())
189                            .map_or_else(|| g.key.to_text(), |c| c.cell(&g.key))
190                    };
191                    if group.column() != 0 {
192                        if let Some(column) = self.columns.get(group.column()) {
193                            label = Text::new(format!("{}: ", column.header())).append_text(&label);
194                        }
195                    }
196                    let len = label.plain().len();
197                    label.stylize(group_style.clone(), 0, len);
198                    let mut header = vec![Text::new(""); self.columns.len()];
199                    if let Some(first) = header.first_mut() {
200                        *first = label;
201                    }
202                    table.add_row_text(header);
203                    for &row in &g.rows {
204                        add(&mut table, &self.rows[row]);
205                    }
206                    if !group.aggregates().is_empty() {
207                        table.add_row_text(self.summary(
208                            console,
209                            group.summary_label(),
210                            group.aggregates(),
211                            &g.aggregates,
212                        ));
213                    }
214                }
215            }
216        }
217        if !self.totals.is_empty() {
218            let values: Vec<Value> = self
219                .totals
220                .iter()
221                .map(|aggregate| aggregate.over(&self.rows, &order))
222                .collect();
223            table.add_row_text(self.summary(console, &self.totals_label, &self.totals, &values));
224        }
225        table
226    }
227
228    fn cells(&self, row: &[Value]) -> Vec<Text> {
229        self.columns
230            .iter()
231            .zip(row)
232            .map(|(column, value)| column.cell(value))
233            .collect()
234    }
235
236    /// A data row with its conditional styles: matching cells stylized over
237    /// their whole text, and a matching row style on the whole row.
238    fn add_styled(&self, table: &mut Table, rules: &ResolvedRules<'_>, row: &[Value]) {
239        let cells = self
240            .cells(row)
241            .into_iter()
242            .enumerate()
243            .map(|(index, mut text)| {
244                if let Some(style) = rules.cell_style(row, index) {
245                    let len = text.plain().len();
246                    text.stylize(style, 0, len);
247                }
248                Cell::Text(text)
249            })
250            .collect();
251        table.add_row_with(cells, rules.row_style(row).map(Into::into), false);
252    }
253
254    /// A summary row: each aggregate in its column (several in one column
255    /// are joined with `, `), and the label in the first cell — before that
256    /// column's own aggregate, if it has one (`total: 6`).
257    fn summary(
258        &self,
259        console: &Console,
260        label: &str,
261        aggregates: &[Aggregate],
262        values: &[Value],
263    ) -> Vec<Text> {
264        let mut cells: Vec<Option<Text>> = vec![None; self.columns.len()];
265        for (aggregate, value) in aggregates.iter().zip(values) {
266            let Some(column) = self.columns.get(aggregate.column()) else {
267                continue;
268            };
269            let text = if aggregate.is_count() {
270                value.to_text()
271            } else {
272                column.cell(value)
273            };
274            let cell = &mut cells[aggregate.column()];
275            *cell = Some(match cell.take() {
276                Some(previous) => previous.append_text(&Text::new(", ")).append_text(&text),
277                None => text,
278            });
279        }
280        if let Some(first) = cells.first_mut() {
281            *first = match first.take() {
282                None => Some(Text::new(label)),
283                Some(value) if label.is_empty() => Some(value),
284                Some(value) => Some(Text::new(format!("{label}: ")).append_text(&value)),
285            };
286        }
287        let aggregate_style = style(console, "table.aggregate");
288        cells
289            .into_iter()
290            .map(|cell| {
291                let mut text = cell.unwrap_or_else(|| Text::new(""));
292                let len = text.plain().len();
293                text.stylize(aggregate_style.clone(), 0, len);
294                text
295            })
296            .collect()
297    }
298}
299
300/// A row padded with `Null` (or cut) to `columns` cells.
301pub(crate) fn normalize(row: impl IntoIterator<Item = Value>, columns: usize) -> Vec<Value> {
302    let mut row: Vec<Value> = row.into_iter().take(columns).collect();
303    row.resize(columns, Value::Null);
304    row
305}
306
307impl Renderable for TableData {
308    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
309        self.to_table(console).rich_render(console, options)
310    }
311
312    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
313        self.to_table(console).measure(console, options)
314    }
315}
316
317impl crate::a11y::AccessibleText for TableData {
318    fn accessible_text(&self, width: usize) -> String {
319        let console = Console::builder().width(width.max(1)).build();
320        self.to_table(&console).accessible_text(width)
321    }
322}