Sorting, grouping and streaming tables¶
The core Table is a faithful port of upstream's: it lays out whatever rows
you give it, in the order you give them. rich_ext::table adds the data
operations upstream leaves to you, and a table for rows that keep changing:
TableData: typed rows under column definitions, with a stable multi-column sort, grouping with per-group aggregates, and a totals row.StreamingTable: rows under stable keys for append/update workloads under a live display. A frame re-renders only the rows that changed.VirtualTable: one window of a source too large to hold, with column widths that stay put while it scrolls.sortandgroup: the same operations as functions over plain rows, for building a coreTableyourself.PlainRowsandPlainTree: data into a coreTableorTreeas plain text, never parsed as markup (below).
rs-rich-data adds a SQL-shaped result set on top (see
SQL result sets).
Every view here builds core tables; none has its own table renderer. So the
output looks exactly like a core Table, box styles, column options, ASCII
fallback and all. No feature flag is needed.
Values and columns¶
A cell is a Value: Null, Int, Float, Str (literal text, never
markup) or Text (styled). The type decides how the cell sorts and
aggregates. The column decides how it looks:
fn services() -> TableData {
let latency = |value: &Value| match value.as_f64() {
Some(ms) => Text::new(format!("{ms:.0} ms")),
None => Text::styled("-", "dim"),
};
let mut data = TableData::new([
Column::new("service"),
Column::new("region"),
Column::new("errors").justify(Justify::Right),
Column::new("p99").justify(Justify::Right).format(latency),
]);
data.push([
"api".into(),
"eu".into(),
Value::Int(3),
Value::Float(120.0),
]);
data.push(["web".into(), "us".into(), Value::Int(0), Value::Float(80.0)]);
data.push(["db".into(), "eu".into(), Value::Int(7), Value::Null]);
data.push([
"cache".into(),
"us".into(),
Value::Int(1),
Value::Float(4.0),
]);
data.push([
"worker-10".into(),
"eu".into(),
Value::Int(3),
Value::Float(95.0),
]);
data.push([
"worker-9".into(),
Value::Null,
Value::Int(0),
Value::Float(60.0),
]);
data
}
Column::new(header)is left-justified..justify(..)sets the justification and.options(ColumnOptions { .. })sets any core column option (width, ratio,no_wrap, style, …)..format(|value| Text)controls how a column displays its values, as here for milliseconds or withrich_ext::formatfor byte sizes. Aggregates are displayed through the same formatter, except counts.Valueconverts from&str,String,Text, the integer types,f64andOption<T>(NoneisNull). A row shorter than the columns is padded withNull, and extra cells are dropped.
Sorting¶
sort_by takes SortKeys in priority order. Later keys break ties between
rows that are equal on the earlier ones:
// Region ascending, then errors descending; the null region sorts last.
let data = services().sort_by([SortKey::asc(1), SortKey::desc(2)]);
console.print(&data);
- Stable. Rows that are equal on every key keep the order they were added in.
- Empty cells last. A
Nullor empty string sorts after every other value in either direction, like the missing region above. - Natural by default. Numbers compare numerically, and so do strings that
are numbers (
"10"after"9"). Other strings compare case-insensitively, with runs of digits compared by value, soworker-9sorts beforeworker-10. Numbers sort before text.SortKey::asc(i).lexical()compares the plain strings byte by byte instead. - Header indicators. Each sorted column's header gets
▲or▼(styledtable.sort_indicator). When there is more than one key, the indicator also shows the key's priority (▲1,▼2).
// Natural order: `worker-9` before `worker-10`.
console.print(&services().sort_by([SortKey::asc(0)]));
On a console that can only render ASCII, the indicators are ^ and v, and
the core table switches to an ASCII box:
// On an ASCII-only console (`Console::builder().ascii_only(true)`) the
// indicators read `^`/`v` and the core swaps in an ASCII box.
console.print(&services().sort_by([SortKey::desc(3)]));
Grouping and aggregates¶
GroupBy::new(column) splits the rows into groups by the value in that
column. Each group renders as three parts:
- A header row. It holds the group's key in the first column, styled
table.group. When the grouped column is not the first column, the column's name is prefixed (region: eu). Empty cells form one group, shown as(empty). - The group's rows.
- A summary row with the group's aggregates, styled
table.aggregate. This row appears only when the grouping has aggregates.
Groups appear in the order of their first row. To get groups in key order, sort by the grouped column first.
let data = services()
.sort_by([SortKey::asc(1), SortKey::asc(0)])
.group_by(
GroupBy::new(1)
.aggregate(Aggregate::sum(2))
.aggregate(Aggregate::max(3)),
)
.totals(
"all",
[Aggregate::count(0), Aggregate::sum(2), Aggregate::mean(3)],
);
console.print(&data);
| Aggregate | Result |
|---|---|
Aggregate::count(col) |
The number of non-empty cells, shown as a plain integer |
Aggregate::sum(col) |
The sum of the numeric cells: an Int if every cell is an integer, otherwise a Float |
Aggregate::min(col), max(col) |
The smallest or largest non-empty cell, in natural order |
Aggregate::mean(col) |
The mean of the numeric cells |
Aggregate::custom(col, f) |
f(&[&Value]) -> Value over the group's cells |
Summary and totals rows:
- Each aggregate is placed in its own column. If a column has more than one
aggregate, they are joined with
,. - The summary label (
subtotalby default, set withGroupBy::label) goes in the first cell. If the first column has its own aggregate, the label is prefixed to it (all: 6). TableData::totals(label, aggregates)adds one final row computed over every row.
To compute groups without rendering them, call
GroupBy::groups(&rows, &order). It returns each group's key, its row
indices and its aggregate values.
Plain rows and a core Table¶
sort::sort_rows, sort::sorted_indices and sort::compare_rows work on any
rows that are AsRef<[Value]>, such as Vec<Vec<Value>>. They let you keep
building the core Table yourself:
use rich::Table;
use rich_ext::table::{sort::sort_rows, SortKey, Value};
let mut rows = vec![
vec![Value::from("v1.10"), Value::Int(2)],
vec![Value::from("v1.9"), Value::Int(5)],
];
sort_rows(&mut rows, &[SortKey::desc(1), SortKey::asc(0)]);
let mut table = Table::new();
table.add_column("version").add_column("n");
for row in &rows {
table.add_row_text(row.iter().map(Value::to_text).collect());
}
Streaming tables¶
StreamingTable<K> keeps its rows under stable keys of type K, such as a
job name, a path or a sequence number:
| Method | Effect |
|---|---|
upsert(key, row) |
Adds a new key at the end. For an existing key, replaces the row in place, keeping its position |
update_cell(&key, col, value) |
Changes one cell |
remove(&key) |
Removes the row and returns its cells |
set_sort(keys) |
Shows the rows sorted (stable over insertion order), with indicators |
window(Window::Tail(n)) |
Shows the last n rows, below an … N earlier rows line |
window(Window::Head(n)) |
Shows the first n rows, above an … N more rows line |
capacity(n) |
Evicts the oldest rows beyond n. Evicted rows count as earlier rows |
fn jobs() -> StreamingTable<&'static str> {
let mut jobs = StreamingTable::new([
Column::new("job"),
Column::new("state"),
Column::new("done")
.justify(Justify::Right)
.format(|v| match v {
Value::Int(n) => Text::new(format!("{n}%")),
_ => Text::new(""),
}),
])
.title("pipeline")
.window(Window::Tail(4)); // log-like: keep the newest rows on screen
// Keys are stable: an upsert of a known key updates its row in place.
for job in ["fetch", "build", "test", "lint", "docs"] {
jobs.upsert(job, [job.into(), "queued".into(), Value::Int(0)]);
}
jobs.update_cell(&"build", 1, "running");
jobs.update_cell(&"build", 2, Value::Int(45));
jobs.upsert("fetch", ["fetch".into(), "done".into(), Value::Int(100)]);
jobs
}
StreamingTable implements Renderable, so you can put it anywhere a
renderable goes, including a LiveCoordinator region (see
Live and layout):
fn run_live(target: RenderTarget) -> Result<(), LiveError> {
let mut jobs = jobs();
let mut live = LiveCoordinator::new(std::io::stdout(), target.clone());
let region = live.add(target.segments(&jobs))?;
live.refresh()?;
for done in [60, 80, 100] {
jobs.update_cell(&"build", 2, Value::Int(done));
// Only the `build` row is laid out again; the rest come from the cache.
live.update(region.clone(), target.segments(&jobs))?;
live.refresh()?;
}
jobs.update_cell(&"build", 1, "done");
live.update(region, target.segments(&jobs))?;
live.finish()?;
let stats = jobs.stats();
eprintln!(
"{} frames, {} row renders, {} relayouts",
stats.frames, stats.rows_rendered, stats.relayouts
);
Ok(())
}
use std::io::IsTerminal;
let interactive = std::io::stdout().is_terminal();
let capabilities = TargetCapabilities {
width: 60,
height: 12,
color_system: Some(ColorSystem::Standard),
interactive,
unicode: true,
hyperlinks: false,
sixel: Support::Unsupported,
};
let kind = if interactive {
TargetKind::Terminal
} else {
TargetKind::PlainStream
};
let target = RenderTarget::new(kind, capabilities, Theme::default_theme());
run_live(target).expect("live output");
What a frame renders again¶
Each row caches three things: its formatted cells, their widths and its rendered lines. Rendering a frame works like this:
- Only rows written since the last frame are formatted again.
- If no column's width changed, only those rows are laid out again. The rest are copied from the cache.
- If a column's width did change, every visible row is laid out again. That happens when a new row widens a column, when the widest row is removed, or when the available width changes.
- Writing a value equal to the current one is not a change.
Value::Textcells are the exception: they always count as changed, because their base style cannot be compared. - Sorting only reorders cached rows. A change to the window only drops or adds rows.
stats() returns counters for this (frames, rows_prepared,
rows_rendered, relayouts), so tests can assert what a frame re-rendered.
The streamed output is byte for byte what to_table() (a core Table of the
same rows, built from scratch) renders, apart from the window's indicator
line. The test suite checks that equality after every step of a mixed
workload.
How this stays exact: the core sizes a column from the widest cell in it and nothing else. Each changed row is rendered by a core table that holds the row plus one extra row made of each column's widest cell. So the row gets exactly the widths the full table would give it.
Gotchas:
- Cached lines keep the styles they were rendered with. If you render the
same table on consoles with different themes, call
invalidate()between them. - Row separators (
show_lines) are not supported. A frame can have a title, a caption, a box style, andshow_edge,expandandborder_style. to_data()takes a snapshot of the rows in display order as aTableData, for grouping and aggregates.
Virtualised tables¶
VirtualTable<S> renders the rows offset..offset + height of a source and
fetches nothing else, so a million-row source costs one window of memory.
The source implements VirtualRows:
| Method | Required | What it does |
|---|---|---|
row(index) |
yes | The row at a 0-based index, or None past the end |
row_count() |
no | The number of rows, when the source knows |
rows(start, len) |
no | A range at once, for sources that fetch ranges cheaply (the default calls row) |
rows_columns(start, len, &columns) |
no | A range with only some columns' cells, for a column window (the default calls rows and picks the cells) |
Vec<Vec<Value>>, slices of rows, references, Box and Arc implement it,
FnRows wraps a closure that makes a row, and FnCells one that makes a
cell (|row, column| value), so only the cells a window shows are made:
use rich::{Console, Justify};
use rich_ext::table::{Column, FnRows, Value, VirtualTable};
// Ten million rows, computed on demand.
let rows = FnRows::new(Some(10_000_000), |i| {
Some(vec![Value::Int(i as i64 + 1), format!("item {i}").into()])
});
let table = VirtualTable::new(
[Column::new("id").justify(Justify::Right), Column::new("name")],
rows,
)
.widths([8, 10])
.offset(1_000)
.height(3);
Console::new().print(&table);
┏━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ id ┃ name ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━┩
│ 1001 │ item 1000 │
│ 1002 │ item 1001 │
│ 1003 │ item 1002 │
└──────────┴────────────┘
rows 1,001–1,003 of 10,000,000
Column widths¶
Widths don't follow the window, so columns never jump while you scroll. Each column's width comes from the first of these that is set:
widths([…]), in column order (Noneskips a column).- The column's own
ColumnOptions::width. - A sample: the header and the first
sample(n)rows (default 100, at most 10,000), capped atmax_column_width(default 40). The sample is taken once and cached;resample()takes it again.
Cells don't wrap, so a row is one line and longer text ends in ….
wrap(true) wraps instead. fit_window(true) also widens sampled columns
to fit the rows shown, for a window rendered once rather than scrolled.
The position line¶
Under the table, a table.position line says where the window is:
| Source | Line |
|---|---|
| Knows its count | rows 1,001–1,040 of 1,000,000 |
| Can't count, more rows follow | rows 1–40 of 41+ |
| Can't count, end reached | rows 961–1,000 of 1,000 |
| Empty | no rows |
An offset past the end of a counted source shows the last full window.
show_position(false) hides the line, and footnote(text) adds one more
line below it. row_numbers(true) adds a # column, and
null_marker("NULL") shows null cells as a dim italic NULL instead of
empty, the way an empty string shows.
Wide tables: a window of columns¶
A table of thousands of columns gets a window across as well as down. Only
the columns it shows are fetched (through rows_columns), sampled for
their widths and laid out, so 2,000 columns cost what a screenful does:
use rich::Console;
use rich_ext::table::{Column, FnCells, Value, VirtualTable};
// 100,000 rows by 2,000 columns, each cell made when it is shown.
let cells = FnCells::new(100_000, 2_000, |r, c| Value::from(r * c));
let columns = (0..2_000).map(|c| Column::new(format!("c{c}")));
let table = VirtualTable::new(columns, cells)
.height(2)
.frozen_columns(1) // `c0` stays on the left
.column_offset(40)
.fit_columns(true); // as many as fit the width
Console::new().print(&table); // 80 columns wide
┏━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┓
┃ c0 ┃ c41 ┃ c42 ┃ c43 ┃ c44 ┃ c45 ┃ c46 ┃ c47 ┃ c48 ┃ c49 ┃ c50 ┃
┡━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━┩
│ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │
│ 0 │ 41 │ 42 │ 43 │ 44 │ 45 │ 46 │ 47 │ 48 │ 49 │ 50 │
└────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
rows 1–2 of 100,000, columns 42–51 of 2,000
| Builder | What it does |
|---|---|
column_offset(n) |
The window's first column, counted from 0 after the frozen ones |
visible_columns(n) |
Show n columns from the offset (with fit_columns, at most n) |
fit_columns(true) |
Show as many columns as fit the width, and always one |
frozen_columns(n) |
Keep the first n columns on the left, never scrolled: an id, a name |
- The position line adds where the column window is when it leaves
columns out:
columns 42–51 of 2,000, counted from 1, the frozen columns aside.Page::column_positiongives it on its own. - Widths are fixed or sampled as for rows, one column at a time as it
comes into view, and kept: scrolling back does not sample again. A fit
samples the columns a few at a time, those that surely fit at
max_column_width, so it looks at most one column past those it shows. - Without any of these builders the table shows every column, as before.
In a viewport¶
VirtualTable is a static renderer. Interactive scrolling belongs to
rs-rich-interact's viewport, which drives the window through these methods:
| Method | Effect |
|---|---|
set_offset(n) / scroll_by(±n) |
Moves the window. scroll_by stops at the top and at the last full window |
set_height(n) |
Shows n rows (at most 10,000) |
max_offset() |
The offset of the last full window, when the count is known |
page(), page_at(width) |
The rows the window shows, its start, the total, whether more follow, and the columns shown at that width (page() lays a fitted window out for 80 cells) |
set_column_offset(n) / scroll_columns_by(±n) |
Moves the column window. scroll_columns_by stops at the first column and at max_column_offset() |
set_visible_columns(Some(n)) |
Shows n columns from the offset, or (None) all, or as many as fit |
shown_columns(width) |
The columns a render width cells wide shows: the frozen ones, then the window's |
chrome_lines(console, width) |
The lines around the rows: title, edges, header, position and footnote |
A viewport of lines lines holds lines - chrome_lines(…) rows:
use rich::Console;
use rich_ext::table::{Column, FnRows, Value, VirtualTable};
let rows = FnRows::new(Some(1_000), |i| Some(vec![Value::from(i)]));
let mut table = VirtualTable::new([Column::new("i")], rows);
let console = Console::builder().width(30).build();
table.set_height(12 - table.chrome_lines(&console, 30));
assert_eq!(console.render_export(&table).lines().count(), 12);
Rows from data files¶
With rs-rich-data, Rows implements VirtualRows. A forward-only
RowSource (a CSV or JSON Lines reader, an Arrow batch stream) can't fetch
by index, so rich_data::window::RowWindow reads it once. It keeps the
window, the first rows (for the width sample) and the count:
use rich_data::window::RowWindow;
let window = RowWindow::reader()
.offset(50_000)
.len(40)
.count(true) // read to the end for the total (the default)
.read(source)?;
console.print(&window.to_virtual_table());
With count(false), reading stops one row past the window, and the position
line reads of 50,041+.
SQL result sets¶
rich_data::sql::ResultSet shows a query result the way a database shell
does. It renders through VirtualTable and needs no database connection:
rows come from Rows, a RowWindow of any adapter, or any VirtualRows
(ResultSet::from_parts(columns, schema, source)).
- Typed alignment. Each column's type comes from the schema (by name, else by position). Integers, floats and decimals are right-justified, booleans centred, and text, dates and timestamps left. A column without a type is right-justified when every sampled non-null cell is a number.
- NULL. A null cell reads
NULLintable.null(dim italic). An empty string stays empty.null_markerchanges the text. - The row count.
(3 rows),(1 row), or(1,001+ rows)when the source can't count, with the elapsed time if you give one:(3 rows, 12ms). - Large results.
limit(default 1,000, at most 10,000) andoffsetchoose the window. A windowed result shows its position line above the count.
use std::time::Duration;
use rich_data::sql::ResultSet;
use rich_data::{DataType, Field, Rows, Schema, Value};
let schema = Schema::new([
Field::new("id", DataType::Integer),
Field::new("name", DataType::String),
Field::new("active", DataType::Boolean),
Field::new("balance", DataType::decimal()),
]);
let mut rows = Rows::new(["id", "name", "active", "balance"]).with_schema(schema);
rows.push([Value::Int(1), "ada".into(), "true".into(), Value::Float(12.5)]);
rows.push([Value::Int(2), "".into(), "false".into(), Value::Null]);
rows.push([Value::Int(10), Value::Null, "true".into(), Value::Float(-3.0)]);
console.print(&ResultSet::new(rows).elapsed(Duration::from_millis(12)));
┏━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━┓
┃ id ┃ name ┃ active ┃ balance ┃
┡━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━━┩
│ 1 │ ada │ true │ 12.5 │
│ 2 │ │ false │ NULL │
│ 10 │ NULL │ true │ -3 │
└────┴──────┴────────┴─────────┘
(3 rows, 12ms)
For a file, ResultSet::read(source, offset, limit) reads one pass of any
RowSource. Run infer::Inferrer over CSV rows first so their columns get
types.
Plain-text cells¶
The core Table::add_row reads each &str as console markup, as upstream
reads a str: "[bold]x[/]" is a bold x, and a [ in a file name or a
column name can restyle or hide what follows. Data you do not control goes
in as a Text, which is never parsed. The PlainRows extension trait does
that wrapping for you, and PlainTree does it for tree labels:
use rich::{Table, Tree};
use rich_ext::table::{PlainRows, PlainTree};
let mut table = Table::new();
table.add_plain_column("file");
table.add_plain_column_justify("size", rich::Justify::Right);
table.add_plain_row(["[draft] notes.md", "12 kB"]);
table.add_plain_rows([["a:smile:b.txt", "1 kB"], ["c.txt", "3 kB"]]);
table.plain_column_footer("[total]");
let mut tree = Tree::new("files");
tree.add_plain("[draft] notes.md");
Every cell, header and footer is shown exactly as written. A plain cell is a
Text cell, so upstream's rules for those apply: a column's highlight
does not highlight it, and emoji codes stay as written. For Columns, use
Columns::from_cells with Cell::from(Text::new(item)).
Styles¶
| Key | Default | Used for |
|---|---|---|
table.group |
bold |
Group header rows |
table.aggregate |
italic |
Summary and totals rows |
table.sort_indicator |
cyan |
▲/▼ in headers |
table.more |
dim |
The window's … N more rows line |
table.position |
dim |
A virtualised table's position line and footnote |
table.null |
dim italic |
The NULL marker |
table.row_number |
dim |
The # column of a virtualised table |
These keys are listed in table::STYLES and included in extended_theme().
If a theme lacks a key, the renderers fall back to the default in this table.
The names don't clash with upstream's table.header, table.footer,
table.cell, table.title and table.caption. Everything reads the same
without colour.
Accessibility¶
TableData, StreamingTable and VirtualTable implement
a11y::AccessibleText through their core Table (for VirtualTable, the
rows of the window, and its columns at the width given). The result is the same Table with N rows, columns: …
summary that a core table produces.