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