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.
Install
Section titled “Install”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.
Run the example
Section titled “Run the example”From a checkout of the repository:
cargo run --release -p gpui-kit-grid --example quickstartA 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.
1. Columns are an enum
Section titled “1. Columns are an enum”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.
2. A row is an immutable value with a key
Section titled “2. A row is an immutable value with a key”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.
3. The delegate styles cells
Section titled “3. The delegate styles cells”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.
4. The window places the grid element
Section titled “4. The window places the grid element”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.
5. The grid state
Section titled “5. The grid state”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).
6. Events
Section titled “6. Events”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.
7. Data from any thread
Section titled “7. Data from any thread”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.
Keys and icons
Section titled “Keys and icons”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 aftergpui_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.
Apache-2.0, built on gpui-kit. Repository Sponsor More from GigLabo Privacy Licenses