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