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