Skip to content

Getting started

gpui-kit-grid is a separate crate on top of gpui-kit. A first grid takes seven pieces: the columns as an enum, a row type that is an immutable value with a key, a delegate that styles cells, the LiveGrid element in a window, the grid’s state with its schema and store, a subscription to its events, and a writer that upserts rows from any thread. This page walks through them in the quickstart example, whose code is shown here as it compiles.

The crate is not on crates.io yet: it depends on gpui-kit by git revision, and crates.io takes no git dependencies. Add both from git, gpui-kit at the revision the grid is built against, so the application and the grid share one gpui-kit and one GPUI:

[dependencies]
gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "146ab45ac0d0cc9dad5f5fd05ecd38da9b3db6e7" }
gpui-kit-grid = { git = "https://github.com/GigLaboCom/gpui-kit-grid", tag = "v1.0.0" }

The application reaches GPUI through gpui-kit (gpui_kit::App, gpui_kit::Window and the rest), as the grid does; it does not list a GPUI crate of its own.

Rust 1.95 or newer. Two features are off by default:

Feature What it adds
decimal CellValue::Decimal and OwnedValue::Decimal with rust_decimal, for exact money values and sums
derive #[derive(GridColumns)], so the column enum needs no hand-written implementation (Columns)

The library builds for wasm32-unknown-unknown too; Web has what changes in the browser.

From a checkout of the repository:

Terminal window
cargo run --release -p gpui-kit-grid --example quickstart

A window with 1000 trades that a producer thread keeps updating, sorted by price. Selecting a row prints its key; Enter or a double click prints the keys it would open.

id is what a saved layout stores, so it must not change; ix is the column’s index in the schema.

// 1. Columns: an enum. `id` is what saved layouts store, `ix` the schema index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum TradeColumn {
Id,
Instrument,
Side,
Quantity,
Price,
}
impl GridColumns for TradeColumn {
fn all() -> &'static [Self] {
use TradeColumn::*;
&[Id, Instrument, Side, Quantity, Price]
}
fn id(self) -> &'static str {
match self {
Self::Id => "id",
Self::Instrument => "instrument",
Self::Side => "side",
Self::Quantity => "quantity",
Self::Price => "price",
}
}
fn ix(self) -> ColumnIx {
ColumnIx::new(self as u16)
}
}

With the derive feature, #[derive(GridColumns)] writes the same implementation.

GridRow::value borrows from the row and must not allocate: it is called for every visible cell on every frame, and for every row when sorting.

// 2. Rows: an immutable value with a key. `value` must not allocate: it runs
// for every visible cell on every frame and for every row when sorting.
#[derive(Clone)]
struct Trade {
id: u64,
instrument: Arc<str>,
sell: bool,
quantity: i64,
price: f64,
}
impl GridRow for Trade {
type Key = u64;
type Column = TradeColumn;
fn key(&self) -> &u64 {
&self.id
}
fn value(&self, column: TradeColumn) -> CellValue<'_> {
match column {
TradeColumn::Id => CellValue::Int(self.id as i64),
TradeColumn::Instrument => CellValue::Text(&self.instrument),
TradeColumn::Side => CellValue::Text(if self.sell { "Sell" } else { "Buy" }),
TradeColumn::Quantity => CellValue::Int(self.quantity),
TradeColumn::Price => CellValue::Float(self.price),
}
}
}

A key can be any Clone + Eq + Hash + Debug + Send + Sync type, a composite struct included; the grid needs neither Copy nor Ord. Selection, focus, highlight, menu and click targets and the copied rows all follow the key, never a position.

The delegate’s hooks take &self and run for visible cells only. They must not read or update the grid entity: the grid is borrowed while it paints.

// 3. Presentation hooks: `&self`, called for visible cells only.
struct Trades;
impl LiveGridDelegate for Trades {
type Row = Trade;
fn cell_style(&self, cell: &CellStyleContext<'_, Trade>, style: &mut CellStyle, cx: &App) {
if cell.row().sell {
style.set_foreground(cx.theme().blue);
}
}
}

LiveGridDelegate has hooks for rows, aggregates, tooltips, the context menu, the primary command, elements in cells, editing and drag payloads; every one has a default. Cells covers the presentation hooks.

LiveGrid is an element (RenderOnce), like gpui-kit’s DataTable. Its size sets the row height, as in gpui-kit’s tables: xsmall 26 px, small 30, medium 32 (the default), large 40. bordered(false) removes the frame.

// 4. The window content places the grid element; `small` = 30 px rows, the
// name is what a screen reader calls the table.
struct TradesWindow {
grid: Entity<LiveGridState<Trades>>,
}
impl Render for TradesWindow {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
LiveGrid::new(&self.grid)
.small()
.accessibility_label("Trades")
}
}

Place LiveGrid::new(&grid) in your layout, not the state entity. LiveGridState is itself a view that LiveGrid embeds, so the grid repaints inside a cached dock panel too. Name every grid with accessibility_label: a screen reader announces the table by it.

The schema lists the columns with their titles and widths (with_frozen(1) keeps the first one in place while the others scroll). new_standalone gives the grid a store of its own; ViewSpec sets the sort, the filter, the grouping or the tree.

// 5. The grid state: schema, delegate, its own store, a sort.
let schema = GridSchema::new()
.with_frozen(1)
.column(GridColumn::new(TradeColumn::Id, "Id").with_width(px(80.)))
.column(GridColumn::new(TradeColumn::Instrument, "Instrument").with_width(px(140.)))
.column(GridColumn::new(TradeColumn::Side, "Side").with_width(px(80.)))
.column(GridColumn::new(TradeColumn::Quantity, "Quantity"))
.column(GridColumn::new(TradeColumn::Price, "Price"));
let grid = cx.new(|cx| {
LiveGridState::new_standalone(schema, Trades, StoreOptions::default(), cx)
.with_view(ViewSpec::new().with_sort([(TradeColumn::Price, SortDirection::Descending)]))
});

Several grids can share one store: build it with GridStore::new(options, cx) and pass it to LiveGridState::new(schema, delegate, store.clone(), cx).

Selection and focus are kept by key, and every change arrives as a LiveGridEvent. Enter, a double click and the primary menu command emit Confirm with the keys they act on.

// 6. Events: selection by key, Enter or a double click. `writeln!` instead
// of `println!`: a print to a closed stdout (a parent that exited,
// `| head`) panics, and a panic here aborts the app.
cx.subscribe(
&grid,
|_, event: &LiveGridEvent<u64, TradeColumn>, _| match event {
LiveGridEvent::SelectionChange { selection, origin } => {
let keys: Vec<_> = selection.iter().collect();
let _ = writeln!(stdout().lock(), "selected {keys:?} ({origin:?})");
}
LiveGridEvent::Confirm { keys, .. } => {
let _ = writeln!(stdout().lock(), "open {keys:?}");
}
_ => {}
},
)
.detach();

The example prints with writeln!, not println!: a print to a closed stdout (a parent that exited, | head) panics, and a panic in a GPUI callback aborts the application.

grid.read(cx).selection() returns the whole selection, focused_cell() the focused cell, and scoped_selection() what a command acts on: the selection within the rows on screen, in display order.

GridWriter is Send + Sync + Clone. replace_all sets a whole data set, upsert inserts or replaces a row by key, and remove takes one out.

// 7. Data from any thread: replace everything, then upsert by key.
let writer = grid.read(cx).writer();
std::thread::spawn(move || {
let instruments: Vec<Arc<str>> = ["SBER", "GAZP", "LKOH", "YNDX", "VTBR"]
.map(Arc::from)
.to_vec();
let mut rng = fastrand::Rng::new();
let mut trades: Vec<Trade> = (0..1_000)
.map(|id| Trade {
id,
instrument: instruments[rng.usize(..instruments.len())].clone(),
sell: rng.bool(),
quantity: rng.i64(1..10_000),
price: rng.f64() * 300.,
})
.collect();
writer.replace_all(trades.iter().cloned().map(Arc::new));
while !writer.is_closed() {
let trade = &mut trades[rng.usize(..1_000)];
trade.price *= 1. + (rng.f64() - 0.5) / 100.;
writer.upsert(trade.clone());
std::thread::sleep(Duration::from_millis(5));
}
});

The writes go to the store’s background ingest, which collapses repeated updates of a key and publishes snapshots; the grid shows a new one at most every 8 ms. Concepts explains the pipeline.

The application starts gpui-kit, then the grid:

fn main() {
gpui_kit::application()
// gpui-kit's icons (sort arrows, menu checks): without them icons are blank.
.with_assets(gpui_kit::assets::Assets)
.run(|cx| {
gpui_kit::init(cx);
// The grid's key bindings: arrows, Page, Home/End, Enter, Escape.
gpui_kit_grid::init(cx);
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(1100.), px(720.)),
cx,
))),
..Default::default()
};
gpui_kit::open_window(options, cx, |_, cx| trades_window(cx)).expect("open window");
cx.activate(true);
});
}
  • gpui_kit_grid::init(cx), called after gpui_kit::init(cx), binds the grid’s keys: arrows, Page Up and Page Down, Home and End, Shift to extend the selection, Cmd/Ctrl+A, Space, Enter, Escape, Cmd/Ctrl+C, Shift+F10 and the Menu key, + and - for groups and tree rows, F2 to edit. Without it the mouse works and the keys do not. Keyboard lists them and shows how to bind your own.
  • The grid draws gpui-kit’s icons (sort arrows, menu checks, chevrons, change arrows), so the application registers gpui-kit’s assets with with_assets(gpui_kit::assets::Assets). Without them the icons are blank.
  • Concepts: rows by key, ingest and snapshots, background rebuilds.
  • Grouping and Tree for structure; Live changes for how updates show.
  • Cookbook for reloads, held order, saved layouts and custom exports.
  • The examples, from this quick start to full live applications.

Apache-2.0, built on gpui-kit. Repository Sponsor More from GigLabo Privacy Licenses