Data tables

A virtualized DataTable with the API of gpui-component's, drawn the way Blender draws its spreadsheet editor, plus a uniform_list for long lists. A million rows scroll in about a millisecond.

  • Rust

A table of a million rows is easy to ask for and hard to build: make one element per cell and the window stops responding. DataTable draws only the cells you can see, straight to the screen, the way Blender’s spreadsheet editor does. Scrolling it costs about a millisecond, whether it holds a hundred rows or a million.

A data table of 3D objects with an ID column pinned at the left, a row-number column, Object, Material, Vertices, Faces, Size and Status columns, sorted by Vertices (descending), with three rows selected and a status line below saying 1000000 rows, showing 0..21, 3 selectedA data table of 3D objects with an ID column pinned at the left, a row-number column, Object, Material, Vertices, Faces, Size and Status columns, sorted by Vertices (descending), with three rows selected and a status line below saying 1000000 rows, showing 0..21, 3 selected
One million rows: sorted by Vertices, three rows selected, the ID column pinned (examples/site_data_table.rs).

A table in 30 lines

You describe your data with a delegate (what the columns are, and the text of any cell), keep the table’s state in an entity, and put a DataTable in your view:

use blenderui::prelude::*;
use blenderui::table::{Cell, CellSource, Column, DataTable, TableDelegate, TableState};
struct People;
// The cells: called for the cells in view only. Numbers are typed; the table
// formats them without allocating.
impl CellSource for People {
fn cell(&self, row: usize, col: usize) -> Cell<'_> {
Cell::U64(if col == 0 { row as u64 } else { row as u64 * 7 % 1000 })
}
}
impl TableDelegate for People {
fn columns_count(&self, _: &App) -> usize { 2 }
fn rows_count(&self, _: &App) -> usize { 1_000_000 }
fn column(&self, ix: usize, _: &App) -> Column {
if ix == 0 { Column::new("id", "ID").sortable().width(px(80.0)).fixed_left() }
else { Column::new("score", "Score").sortable() }
}
}
struct App1 { table: Entity<TableState<People>> }
impl Render for App1 {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
rsx! {
<div class="w-full h-full">
{DataTable::new(&self.table).stripe(true)}
</div>
}
}
}

Column sets the width, whether it sorts, resizes, moves or is pinned, and how its text is aligned. A click on a sortable header sorts ascending, then descending, then back to the original order. Drag a header’s right edge to resize it (double-click it to fit the content when your delegate implements fit_column), and drag a header sideways to move the column.

Why it is fast

Blender’s spreadsheet doesn’t make a widget per cell. Each frame it works out which rows the scroll position shows (first_row = scroll_y / row_height, and region_height / row_height + 1 of them), skips the columns that are off screen, and draws just those cells. BUI’s table does the same:

  • Only visible cells are asked for. CellSource::cell runs for the cells in view, never for the rest. The table has no per-row state, so building one costs nothing.
  • Cells are painted, not built. There’s no element, style or layout box per cell, so a frame builds the same handful of elements however many rows there are.
  • Sorting and filtering never copy rows. They produce a list of row numbers (what Blender calls an index mask) and the table reads through it. A column of numbers or of text sorts through typed keys: one pass pulls an order-preserving integer out of each cell, then plain integer pairs are sorted on every core, with no call into your code per comparison.
  • A steady frame allocates nothing. cell hands back a borrowed &str or a number that the table formats into a stack buffer, and the table’s own drawing is covered by a test that counts allocations.
  • Nothing moves, nothing renders. A table that isn’t scrolling costs no CPU.

Rust data table: scroll frame, 1,000,000 rows x 20 columns

Whole frame, 900x500 window, software raster. One element per cell: 2,000 rows

Lower is better

1.1 msper scrolled frame at a million rows

  • BUI
  • Others
  • DataTablepainted like Blender's spreadsheet1.1 ms
  • uniform_list20 cell elements per visible row3.1 ms
  • One element per cell2,000 rows only1,735 ms1577× more
Open bench
Rust data table, 1,000,000 rows x 20 columns in a 900x500 window (examples/bench_table.rs, release build, software raster)
DataTable (painted)uniform_list rowsOne element per cell
Rows measured1,000,0001,000,0002,000
Build0.17 ms--
First frame6-8 ms13 ms2,800 ms
Scroll frame1.1 ms3.1 ms1,735 ms
Heap allocations per frame (whole pipeline)485,186780,467
Allocations in the table's own drawing0--
Memory added (working set)17 MB19 MB138 MB
Sort 1,000,000 rows by a column (header click, includes the repaint)22 ms--

Frame times are the whole pipeline: render, layout, paint and the software raster. One element per cell was measured at 2,000 rows (a million would need about 70 GB and over ten minutes per frame; its cost grows linearly with rows). Allocation counts come from a counting global allocator; the table's drawing allocates nothing in steady state (a unit test asserts it). The same table on real gpui against gpui-component is in the next chart.

Source: bindings/rust/blenderui/examples/bench_table.rs · Ryzen 7 5800U laptop, Windows 11, on AC power

The same 1,000,000 x 20 table, three ways, measured by examples/bench_table.rs (a 900x500 window; “frame” is the whole pipeline including the software raster). The last bar is a table built the obvious way, one element per cell, measured at 2,000 rows because it can’t be measured at a million.

One core, two frameworks

The table is split so that nothing is written twice. The blenderui-table crate has no dependencies and no framework: the visible-row math, column culling, the row index mask and the typed sorts, the selection model, keyboard navigation, column layout, Ctrl+C text, scrolling, hit testing, a bounded text-run cache, and the painting itself, which draws through a small PaintSink trait. A framework adapter adds three things and no logic: a Host that forwards your delegate’s hooks and the redraw calls, a PaintSink that draws rectangles and text, and an element that hands pointer, wheel and key events to the core.

blenderui’s own table module and the gpui adapter below are both such adapters, around the same TableCore.

On gpui

blenderui-table-gpui is a DataTable for plain gpui with gpui-component’s names (TableDelegate, Column, ColumnSort, ColumnGroup, TableState, TableEvent, scroll_to_row, load_more …). It is a custom gpui Element that paints the visible cells with Window::paint_quad and shaped lines: two elements per frame, however many rows. It keeps shaped text in a cache keyed by text, color and size, so scrolling never reshapes a string it showed a frame ago.

use blenderui_table_gpui::*;
struct People;
impl CellSource for People {
fn cell(&self, row: usize, col: usize) -> Cell<'_> {
match col {
0 => Cell::U64(row as u64),
1 => Cell::Str(["Cube", "Sphere", "Torus"][row % 3]),
_ => Cell::F64(row as f64 / 8.0, 2),
}
}
}
impl TableDelegate for People {
fn columns_count(&self, _: &App) -> usize { 3 }
fn rows_count(&self, _: &App) -> usize { 1_000_000 }
fn column(&self, ix: usize, _: &App) -> Column {
Column::new(format!("c{ix}"), ["ID", "Name", "Size"][ix]).sortable().width(px(110.0))
}
}
// in render: div().size_full().child(DataTable::new(&self.table).stripe(true))

For a cell that needs real content, return true from has_custom_cells and is_custom_cell(row, col), return Cell::Empty from cell, and build the element in render_td; only visible custom cells are built.

Against gpui-component

Measured in a real window on the same gpui, with the same data, columns and row height, one process per library:

gpui: scroll frame, 1,000,000 rows x 20 columns

Main thread, render to end of paint, median of 3 runs, real window

Lower is better

3.3xless CPU per scrolled frame than gpui-component

  • BUI
  • Others
  • blenderui-table-gpuipaints visible cells1.45 ms
  • gpui-component 0.9.0an element per cell4.75 ms3.3× more
  • Empty windowthe framework alone0.15 ms
Open bench
Data table on real gpui: this crate vs gpui-component 0.9.0, 1,000,000 rows x 20 columns, 1200x700 table in a 1280x740 window (bindings/rust/blenderui-table-gpui, examples/bench_gpui.rs, release build, Direct3D 11)
blenderui-table-gpuigpui-component 0.9.0Empty window (no table)
Scroll frame, render to end of paint, median1.45 ms4.75 ms0.15 ms
Scroll frame, 95th / 99th percentile1.77 / 2.11 ms8.99 / 9.58 ms0.26 / 0.30 ms
Frames per second under continuous scrolling60.160.060.1
Process CPU per frame (all threads)4.6 ms8.9 ms1.0 ms
Heap allocations per scrolled frame2597,623102
Heap allocations per repainted frame (no scroll)1367,416102
Selection (click) to painted, CPU1.05 ms5.27 ms-
Keyboard (Down) to painted, CPU1.63 ms8.85 ms-
Sort 1,000,000 rows by a number37 ms140 ms-
Sort 1,000,000 rows by text38 ms57 ms-
First frame (window creation dominates)431 ms445 ms438 ms
Memory above the data and the empty runtime42 MB42 MB-
Peak working set231 MB192 MB-
100,000 x 8: scroll frame median1.24 ms4.02 ms-
100,000 x 8: sort by a number / by text4.2 / 6.8 ms8.6 / 3.9 ms-

Ryzen 7 5800U laptop (8 cores), 15 GB, Windows 11; the GPU is whatever gpui picks on the default Direct3D 11 adapter. One process per library, median of 3 runs. Frame time is the main thread from the start of the root view's render to the end of the table's paint (elements, layout, prepaint, paint); GPU submission and the 60 Hz vsync wait are not in it, so both libraries show 60 FPS and the difference is CPU time. Scrolling is two rows per frame, driven the same way for both. Allocations come from a counting global allocator on the UI thread. Select and key inject real events through Window::dispatch_event and count the dispatch plus the next frame. Both tables hold the same data (columnar, generated once) and the same 110 px columns and 32 px rows; the component sorts its own row index with sort_by in perform_sort, as its examples sort their data, while this crate sorts a row mask with typed keys on every core. At 100,000 rows the component's plain single-threaded sort wins on text. This crate's peak memory is higher because sorting builds key pairs. Not measured: GPU time, a different GPU, other platforms.

Source: bindings/rust/blenderui-table-gpui/bench/results.md · Ryzen 7 5800U laptop, Windows 11, on AC power

The frame time is the main thread’s CPU time from render to the end of paint; it leaves out GPU work and the 60 Hz wait, which is why both show 60 FPS. The gaps are the point: about 3.3 times less time per scrolled frame, about 30 times fewer allocations, and a worse tail for the component (95th percentile 9 ms against 1.8 ms). The component’s own text sort wins at 100,000 rows of text; ours wins on numbers there and on every sort at a million rows. Peak memory is higher here because sorting builds key pairs. Reproduce it with cargo build --release --features bench --example bench_gpui -j 1 and node bench/run.mjs in bindings/rust/blenderui-table-gpui. GPU time and other machines are not measured.

What you get

Feature How
Sticky header, pinned columns, horizontal and vertical scrolling built in; Column::fixed_left() pins a column
Column resize, move, sort drag a header border, drag a header, click a header
Row selection click; Ctrl-click toggles; Shift-click extends; Ctrl+A selects all
Cell and column selection cell_selectable(true), col_selectable(true) (one at a time)
Keyboard Up, Down, Page Up, Page Down, Home, End; Left and Right in cell and column modes; Escape clears
Copy Ctrl+C puts the selected rows on the clipboard as tab-separated text
Stripes, row-number column, borders stripe(true), row_header(true), bordered(true)
Row sizes Size::XSmall to Size::Large, from Blender’s UI_UNIT_Y and the UI scale
Loading and empty states loading() shows a skeleton; empty_text()
More rows when scrolled to the end has_more, load_more_threshold, load_more
Filter `TableState::set_filter(
Themes Blender’s dark and light themes, with HiDPI

Events come as TableEvent values, the same names gpui-component uses (SelectRow, DoubleClickedRow, ColumnWidthsChanged, MoveColumn, RightClickedRow …). Listen with on_event, or with on_select, on_sort, on_row_double_click, on_column_resize and on_context_menu:

DataTable::new(&table)
.stripe(true)
.on_select(|row, _window, _cx| println!("row {row}"))
.on_sort(|col, sort, _, _| println!("column {col}: {sort:?}"))

Porting from gpui-component

The names follow gpui-component’s TableDelegate, Column, ColumnSort, ColumnGroup, TableState and DataTable: columns_count, rows_count, column, perform_sort, fit_column, move_column, group_headers, loading, has_more, load_more, visible_rows_changed, visible_columns_changed, selected_row, set_selected_row, visible_range, scroll_to_row, scroll_to_col, headers and dump. Two things differ on purpose:

  • Cells are values drawn by the table. gpui-component’s render_td returns an element for every visible cell on every frame; here cell returns a typed value (Cell::U64, Cell::Str, Cell::F64(value, decimals)) and the table draws it, which is what makes a frame cost the same at any size. On gpui, render_td remains as an opt-in for the few cells that need real elements. In blenderui, cells that hold widgets aren’t supported yet.
  • The table sorts for you. The cells’ own order sorts an index list (override CellSource::sort_kind and compare for a custom order); the delegate’s data is never reordered. If you’d rather sort your own data in perform_sort, return false from sort_by_mask.

Row numbers in events and in selected_row are your delegate’s row numbers, so they stay right when the table is sorted or filtered.

Long lists: uniform_list

For a list of equal-height items that are real elements (buttons, icons, rows with their own widgets), uniform_list takes gpui’s signature and builds only the items in view:

uniform_list("files", 100_000, |range, _window, _cx| {
range.map(|i| div().child(format!("File {i}"))).collect::<Vec<_>>()
})
.item_height(px(20.0))
.h(px(300.0))

It uses the same visible-range code as the table. The scroll offset is kept per id like any scrolling element, and the scrollbar is sized for all the items. Layout works in f32, so past about 16 million pixels of total height (a million 24 px rows) the position loses sub-pixel precision; the DataTable keeps its own offset in f64 and has no such limit.

Testing a table

Tables run off-screen like everything else, so your tests can click headers, drag borders and press keys. tests/table.rs does exactly that, on a million-row table; see headless testing.

Limitations

  • No element cells in blenderui yet (the gpui adapter has the render_td escape hatch), and no popup context menu: right clicks arrive as TableEvent::RightClickedRow.
  • Selection after a sort or filter is cleared, because selected rows are display positions.
  • Cell and column selection hold one cell or column, not a range. Row selection takes ranges.
  • Column groups (group_headers) draw, but aren’t covered by a test yet.
  • JavaScript and Python wrappers: coming soon. The table is built once, in Rust; the other languages will wrap it rather than reimplement it.
  • Damage rectangles inside the table aren’t reported. A scroll or hover redraws the table’s canvas as a whole; the engine’s damage rendering for the rest of the window is unchanged.
Edit this page on GitHub

Last updated