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