Skip to main content

rich_ext/table/
mod.rs

1//! Table data operations layered on the core [`rich::Table`].
2//!
3//! The core `Table` is a faithful port of upstream and renders whatever rows
4//! it is given. This module adds the parts upstream leaves to the caller,
5//! without forking the table renderer — everything here *builds* core tables:
6//!
7//! * [`Value`] and [`Column`]: typed cells and column definitions (header,
8//!   core [`ColumnOptions`], an optional formatter), shared by the views below.
9//! * [`sort`]: stable multi-column sorting with natural/numeric comparison,
10//!   empty cells last, and `▲`/`▼` header indicators.
11//! * [`group`]: grouping by a column with group header rows and per-group
12//!   aggregates (count, sum, min, max, mean, custom) as summary rows.
13//! * [`TableData`]: plain rows plus a sort, a grouping and totals, rendered as
14//!   one core `Table`.
15//! * [`stream`]: [`StreamingTable`], keyed rows for append/update workloads
16//!   under a live display, re-rendering only the rows that changed.
17//! * [`rules`]: conditional styles, rules that style a cell, row or column
18//!   by value ([`TableData::style_rules`]).
19//! * [`plain`]: [`PlainRows`] and [`PlainTree`], data into a core `Table`
20//!   or `Tree` as plain text, never parsed as markup.
21//! * [`virtualized`]: [`VirtualTable`], one window of a large row source
22//!   ([`VirtualRows`]) with fixed or sampled column widths, in constant
23//!   memory.
24//!
25//! ```
26//! use rich::{Console, Justify};
27//! use rich_ext::table::{Column, SortKey, TableData, Value};
28//!
29//! let mut data = TableData::new([
30//!     Column::new("service"),
31//!     Column::new("p99 ms").justify(Justify::Right),
32//! ]);
33//! data.push(["web".into(), Value::Int(120)]);
34//! data.push(["api".into(), Value::Int(35)]);
35//! data.push(["db".into(), Value::Null]);
36//! let data = data.sort_by([SortKey::asc(1)]);
37//!
38//! let console = Console::builder().width(40).build();
39//! let out = console.render_export(&data);
40//! let lines: Vec<&str> = out.lines().collect();
41//! assert_eq!(lines[1], "┃ service ┃ p99 ms ▲ ┃");
42//! assert_eq!(lines[3], "│ api     │       35 │");
43//! assert_eq!(lines[5], "│ db      │          │"); // empty cells sort last
44//! ```
45//!
46//! Theme keys are listed in [`STYLES`]; [`extended_theme`] includes them and
47//! the renderers fall back to them when a theme lacks a key. Every renderer
48//! reads as plain text without colour.
49//!
50//! [`extended_theme`]: crate::theme::extended_theme
51
52pub mod data;
53pub mod group;
54pub mod plain;
55pub mod rules;
56pub mod sort;
57pub mod stream;
58pub mod transform;
59pub mod virtualized;
60
61use std::fmt;
62use std::sync::Arc;
63
64use rich::r#box::Box as BoxSet;
65use rich::{ColumnOptions, Console, Justify, Style, Table, Text};
66
67pub use data::TableData;
68pub use group::{Aggregate, Group, GroupBy};
69pub use plain::{PlainRows, PlainTree};
70pub use rules::{ColumnRef, Comparison, ResolvedRules, RuleError, StyleRule, StyleRules, Target};
71pub use sort::{Compare, Order, SortKey};
72pub use stream::{RenderStats, StreamingTable, Window};
73pub use virtualized::{FnCells, FnRows, Page, VirtualRows, VirtualTable};
74
75/// Theme keys used by this module, with their fallback styles.
76///
77/// The names avoid upstream's own `table.header`, `table.footer`,
78/// `table.cell`, `table.title` and `table.caption`.
79pub const STYLES: &[(&str, &str)] = &[
80    ("table.group", "bold"),
81    ("table.aggregate", "italic"),
82    ("table.sort_indicator", "cyan"),
83    ("table.more", "dim"),
84    ("table.position", "dim"),
85    ("table.null", "dim italic"),
86    ("table.row_number", "dim"),
87];
88
89/// A theme style for `key`, falling back to [`STYLES`].
90pub(crate) fn style(console: &Console, key: &str) -> Style {
91    let fallback = STYLES
92        .iter()
93        .find(|(name, _)| *name == key)
94        .map_or("", |(_, spec)| *spec);
95    crate::event::theme_style(console, key, fallback)
96}
97
98/// A typed table cell.
99///
100/// The type drives sorting ([`sort`]) and aggregation ([`group`]); the
101/// column's formatter (or [`Value::to_text`]) drives display.
102#[derive(Clone, Debug, Default)]
103pub enum Value {
104    /// No value. Renders empty and sorts last.
105    #[default]
106    Null,
107    /// An integer.
108    Int(i64),
109    /// A floating-point number.
110    Float(f64),
111    /// Literal text (never parsed as markup).
112    Str(String),
113    /// Styled text; sorts and groups by its plain string.
114    Text(Text),
115}
116
117impl Value {
118    /// Whether the value is [`Null`](Value::Null) or has an empty string.
119    pub fn is_empty(&self) -> bool {
120        match self {
121            Value::Null => true,
122            Value::Str(s) => s.is_empty(),
123            Value::Text(t) => t.plain().is_empty(),
124            Value::Int(_) | Value::Float(_) => false,
125        }
126    }
127
128    /// The value as a number, for `Int` and `Float` only.
129    pub fn as_f64(&self) -> Option<f64> {
130        match self {
131            Value::Int(n) => Some(*n as f64),
132            Value::Float(f) => Some(*f),
133            _ => None,
134        }
135    }
136
137    /// The plain display string: empty for `Null`, `Display` for numbers.
138    pub fn plain(&self) -> String {
139        match self {
140            Value::Null => String::new(),
141            Value::Int(n) => n.to_string(),
142            Value::Float(f) => f.to_string(),
143            Value::Str(s) => s.clone(),
144            Value::Text(t) => t.plain().to_string(),
145        }
146    }
147
148    /// The default display: [`plain`](Value::plain) as literal text, or the
149    /// `Text` itself.
150    pub fn to_text(&self) -> Text {
151        match self {
152            Value::Text(t) => t.clone(),
153            other => Text::new(other.plain()),
154        }
155    }
156}
157
158/// Values compare by variant and content (floats bit for bit). A
159/// [`Value::Text`] never equals anything, itself included, because its base
160/// style cannot be compared: [`StreamingTable`] treats writing one as a change.
161/// Prefer [`Value::Str`] for data.
162impl PartialEq for Value {
163    fn eq(&self, other: &Self) -> bool {
164        match (self, other) {
165            (Value::Null, Value::Null) => true,
166            (Value::Int(a), Value::Int(b)) => a == b,
167            (Value::Float(a), Value::Float(b)) => a.to_bits() == b.to_bits(),
168            (Value::Str(a), Value::Str(b)) => a == b,
169            _ => false,
170        }
171    }
172}
173
174impl From<&str> for Value {
175    fn from(s: &str) -> Self {
176        Value::Str(s.to_string())
177    }
178}
179
180impl From<String> for Value {
181    fn from(s: String) -> Self {
182        Value::Str(s)
183    }
184}
185
186impl From<Text> for Value {
187    fn from(t: Text) -> Self {
188        Value::Text(t)
189    }
190}
191
192impl From<f64> for Value {
193    fn from(f: f64) -> Self {
194        Value::Float(f)
195    }
196}
197
198macro_rules! int_value {
199    ($($t:ty),*) => {$(
200        impl From<$t> for Value {
201            fn from(n: $t) -> Self {
202                i64::try_from(n).map_or(Value::Float(n as f64), Value::Int)
203            }
204        }
205    )*};
206}
207int_value!(i8, i16, i32, i64, u8, u16, u32, u64, usize, isize);
208
209impl<T: Into<Value>> From<Option<T>> for Value {
210    fn from(v: Option<T>) -> Self {
211        v.map_or(Value::Null, Into::into)
212    }
213}
214
215/// Formats a [`Value`] for display in a column.
216pub type Formatter = Arc<dyn Fn(&Value) -> Text + Send + Sync>;
217
218/// A column definition: a header, core [`ColumnOptions`] and an optional
219/// formatter.
220///
221/// ```
222/// use rich::{Justify, Text};
223/// use rich_ext::table::{Column, Value};
224///
225/// let size = Column::new("size")
226///     .justify(Justify::Right)
227///     .format(|v| match v.as_f64() {
228///         Some(n) => Text::new(rich_ext::format::bytes(n as u64)),
229///         None => Text::new(""),
230///     });
231/// assert_eq!(size.cell(&Value::Int(2048)).plain(), "2.0 kB");
232/// ```
233#[derive(Clone)]
234pub struct Column {
235    header: String,
236    options: ColumnOptions,
237    formatter: Option<Formatter>,
238}
239
240impl fmt::Debug for Column {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        f.debug_struct("Column")
243            .field("header", &self.header)
244            .field("options", &self.options)
245            .field("formatter", &self.formatter.is_some())
246            .finish()
247    }
248}
249
250impl Column {
251    /// A left-justified column. The header is literal text, not markup.
252    pub fn new(header: impl Into<String>) -> Self {
253        Column {
254            header: header.into(),
255            options: ColumnOptions::default(),
256            formatter: None,
257        }
258    }
259
260    /// Justify the column's cells.
261    #[must_use]
262    pub fn justify(mut self, justify: Justify) -> Self {
263        self.options.justify = justify;
264        self
265    }
266
267    /// Replace every core column option (width, ratio, no_wrap, style, …).
268    #[must_use]
269    pub fn options(mut self, options: ColumnOptions) -> Self {
270        self.options = options;
271        self
272    }
273
274    /// Display values through `formatter` instead of [`Value::to_text`].
275    /// Aggregates other than counts are formatted the same way.
276    #[must_use]
277    pub fn format(mut self, formatter: impl Fn(&Value) -> Text + Send + Sync + 'static) -> Self {
278        self.formatter = Some(Arc::new(formatter));
279        self
280    }
281
282    /// The header text.
283    pub fn header(&self) -> &str {
284        &self.header
285    }
286
287    /// The core column options.
288    pub fn column_options(&self) -> &ColumnOptions {
289        &self.options
290    }
291
292    /// A value as this column displays it.
293    pub fn cell(&self, value: &Value) -> Text {
294        match &self.formatter {
295            Some(format) => format(value),
296            None => value.to_text(),
297        }
298    }
299}
300
301/// The header text of every column, with a sort indicator on sorted columns.
302/// With more than one key each indicator carries its priority (`▲1`, `▼2`).
303pub(crate) fn headers(console: &Console, columns: &[Column], keys: &[SortKey]) -> Vec<Text> {
304    let ascii = console.ascii_only();
305    columns
306        .iter()
307        .enumerate()
308        .map(|(index, column)| {
309            let mut text = Text::new(column.header.clone());
310            if let Some(priority) = keys.iter().position(|k| k.column == index) {
311                let mut mark = sort::indicator(keys[priority].order, ascii).to_string();
312                if keys.len() > 1 {
313                    mark.push_str(&(priority + 1).to_string());
314                }
315                text.append(" ", None);
316                text.append(&mark, Some(style(console, "table.sort_indicator").into()));
317            }
318            text
319        })
320        .collect()
321}
322
323/// Presentation shared by the views: what a core `Table` is built with.
324#[derive(Clone, Debug)]
325pub(crate) struct Frame {
326    pub title: Option<String>,
327    pub caption: Option<String>,
328    /// `None` draws no box (`Table::without_box`).
329    pub box_set: Option<BoxSet>,
330    pub show_edge: bool,
331    pub expand: bool,
332    pub border_style: Style,
333}
334
335impl Default for Frame {
336    fn default() -> Self {
337        Frame {
338            title: None,
339            caption: None,
340            box_set: Some(rich::r#box::HEAVY_HEAD),
341            show_edge: true,
342            expand: false,
343            border_style: Style::new(),
344        }
345    }
346}
347
348impl Frame {
349    /// An empty core table with this frame and the given columns.
350    pub fn table(
351        &self,
352        columns: &[Column],
353        headers: &[Text],
354        show_header: bool,
355        annotations: bool,
356    ) -> Table {
357        let mut table = Table::new()
358            .show_header(show_header)
359            .show_edge(self.show_edge)
360            .expand(self.expand)
361            .border_style(self.border_style.clone());
362        table = match self.box_set {
363            Some(box_set) => table.box_set(box_set),
364            None => table.without_box(),
365        };
366        if annotations {
367            if let Some(title) = &self.title {
368                table = table.title(title.clone());
369            }
370            if let Some(caption) = &self.caption {
371                table = table.caption(caption.clone());
372            }
373        }
374        for (column, header) in columns.iter().zip(headers) {
375            table.add_column_with(header.clone(), column.options.clone());
376        }
377        table
378    }
379
380    /// Lines drawn above and below the body by the box edges.
381    pub fn edge_lines(&self) -> usize {
382        usize::from(self.box_set.is_some() && self.show_edge)
383    }
384}
385
386/// Builder methods for the shared [`Frame`], generated for each view.
387macro_rules! frame_builders {
388    ([$($g:ident)?] $ty:ty) => {
389        impl$(<$g>)? $ty {
390            /// A centered title above the table (console markup, as the core
391            /// `Table::title`).
392            pub fn title(mut self, title: impl Into<String>) -> Self {
393                self.frame.title = Some(title.into());
394                self
395            }
396            /// A centered caption below the table (console markup).
397            pub fn caption(mut self, caption: impl Into<String>) -> Self {
398                self.frame.caption = Some(caption.into());
399                self
400            }
401            /// The box-drawing set (default `HEAVY_HEAD`, as the core table).
402            pub fn box_set(mut self, box_set: rich::r#box::Box) -> Self {
403                self.frame.box_set = Some(box_set);
404                self
405            }
406            /// Draw no borders and no column dividers.
407            pub fn without_box(mut self) -> Self {
408                self.frame.box_set = None;
409                self
410            }
411            /// Draw the outer edges (default on).
412            pub fn show_edge(mut self, show: bool) -> Self {
413                self.frame.show_edge = show;
414                self
415            }
416            /// Expand to the full available width.
417            pub fn expand(mut self, expand: bool) -> Self {
418                self.frame.expand = expand;
419                self
420            }
421            /// Style the box border.
422            pub fn border_style(mut self, style: rich::Style) -> Self {
423                self.frame.border_style = style;
424                self
425            }
426        }
427    };
428}
429pub(crate) use frame_builders;