Skip to content

Cookbook

Short recipes for tasks that come up in live applications. Each one is a function of the cookbook example, which builds with the rest of the repository, so the code here cannot drift from the API. The example is a quotes window whose toolbar runs them:

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

A user presses Refresh, and the application reloads every row from a slow source while live updates keep arriving. begin_reload makes the live writes wait; when the new rows are in, they are applied on top of the loaded ones, so nothing written during the load is lost and nothing older wins. One batch shows the loaded rows and clears the progress bar in the same snapshot.

/// Reloads every quote from a slow source while live updates go on: a bar
/// under the header says so until the new rows are on screen.
fn reload_with_progress(
writer: &GridWriter<Quote>,
load: impl FnOnce() -> Vec<Quote> + Send + 'static,
) {
// From now on, live writes wait and are replayed over the loaded rows.
let reload = writer.begin_reload();
writer.set_progress(Some(
GridProgress::indeterminate().with_label("Reloading quotes"),
));
let writer = writer.clone();
std::thread::spawn(move || {
let rows = load();
// One snapshot shows the loaded rows, the replayed writes and no bar.
writer.batch(|batch| {
batch.finish_reload(reload, rows.into_iter().map(Arc::new));
batch.set_progress(None);
});
});
}
  • GridProgress::new(fraction) shows a determinate bar instead.
  • A Reload dropped without finish cancels the reload; the waiting writes apply as usual.
  • GridStore::builder(options).with_reload_policy(policy) replaces how the two meet: a policy that knows row versions can drop live writes older than the snapshot.

A sorted grid under a fast stream reorders all the time. HoldReorder keeps the order on screen while the pointer is over the grid, for at most a few seconds, while the values keep updating in place; LiveReorder, the default, shows every order at once. A “Refresh order” button shows a held order on demand.

/// Holds the order while the user reads: with `hold`, a new order waits
/// while the pointer is over the grid, at most three seconds (and as long
/// as a menu or an editor is open). Without it, every new order shows at
/// once, a held one too.
fn set_hold_order(grid: &QuotesGrid, hold: bool, cx: &mut App) {
grid.update(cx, |grid, cx| {
if hold {
grid.set_reorder_policy(HoldReorder::new(Duration::from_secs(3)), cx);
} else {
grid.set_reorder_policy(LiveReorder, cx);
}
});
}
/// Keeps `Root::order_held` in step with the grid, for a "Refresh order"
/// button that shows the held order at once.
fn watch_held_order(grid: &QuotesGrid, cx: &mut Context<Root>) -> Subscription {
cx.subscribe(
grid,
|root, _, event: &LiveGridEvent<u32, QuoteColumn>, cx| {
if let LiveGridEvent::OrderHeldChange { held } = event {
root.order_held = *held;
cx.notify();
}
},
)
}
fn refresh_order(grid: &QuotesGrid, cx: &mut App) {
grid.update(cx, |grid, cx| grid.apply_held_order(cx));
}
  • Every built-in policy also holds the order while the context menu or a cell editor is open, and a user’s own action (a click on a header) shows the new order at once.
  • ManualReorder holds until apply_held_order, for a grid that reorders only when asked.
  • set_reorder_policy hands an order that is already held to the new policy straight away.

The layout is the columns’ order, widths, zones and visibility, the sort, the grouping and its expansion. Read it at startup, apply it with with_layout, and write it back on the four events that change it:

/// A grid whose layout (column order, widths, frozen and hidden columns,
/// the sort, the grouping and its expansion) is read from `path` at startup
/// and written back after every change.
fn grid_with_saved_layout(store: GridStore<Quote>, path: PathBuf, cx: &mut App) -> QuotesGrid {
// A missing or unreadable file starts with the schema's layout.
let saved = std::fs::read_to_string(&path)
.ok()
.and_then(|text| JsonLayoutCodec::default().decode(&text).ok());
let grid = cx.new(|cx| {
let grid = LiveGridState::new(schema(), Quotes, store, cx)
// Names the kind of grid: a layout saved for another one is refused.
.with_view_id("quotes")
.with_view(ViewSpec::new().with_sort([(QuoteColumn::Last, SortDirection::Descending)]));
match saved {
Some(layout) => grid.with_layout(layout),
None => grid,
}
});
cx.subscribe(
&grid,
move |grid, event: &LiveGridEvent<u32, QuoteColumn>, cx| {
// The four events that change what a layout holds. A small file is
// written at once; a larger state would debounce the write.
if matches!(
event,
LiveGridEvent::LayoutChange { .. }
| LiveGridEvent::SortChange { .. }
| LiveGridEvent::GroupChange { .. }
| LiveGridEvent::ExpansionChange { .. }
) && let Ok(text) = grid.read(cx).export_layout()
&& let Err(error) = std::fs::write(&path, text)
{
out!("layout not saved: {error}");
}
},
)
.detach();
grid
}
  • with_view_id names the kind of grid, so a layout saved for another one is refused rather than half-applied.
  • A layout survives schema changes: unknown columns are ignored and new ones are added at the end.
  • A real application debounces the write; blotter saves 0.5 s after the last change and on exit, and dock_layout keeps every panel’s layout and named layouts in one file.

export_with hands one snapshot of what the grid shows to a closure in the background and resolves with what it returns: here a Markdown table, with the numbers as the grid shows them.

/// The lines on screen as a Markdown table, written in the background from
/// one snapshot of the grid; the job resolves with the text.
fn export_markdown(
grid: &mut LiveGridState<Quotes>,
cx: &mut Context<LiveGridState<Quotes>>,
) -> Result<ExportJob<String>, ExportError> {
let request = ExportRequest::new(ExportScope::Visible);
grid.export_with(
request,
|table, cells, control| {
let mut out = String::new();
let columns = table.columns();
for column in columns {
write!(out, "| {} ", column.title()).ok();
}
out.push_str("|\n");
out.push_str(&"|---".repeat(columns.len()));
out.push_str("|\n");
let mut text = String::new();
for (ix, row) in table.rows().iter().enumerate() {
for column in columns {
text.clear();
// The text a copy writes: numbers with the decimals shown.
if let Some(cell) = row.cell(column.column()) {
cells.write_cell(&cell, &mut text);
}
write!(out, "| {} ", text.replace('|', "\\|")).ok();
}
out.push_str("|\n");
// Progress, and the point where a cancelled export stops.
if ix % 1024 == 1023 {
control.advance(1024)?;
}
}
control.advance(table.rows().len() % 1024)?;
Ok(out)
},
cx,
)
}
  • control.advance reports progress and is where a cancelled export stops.
  • The same closure writes xlsx with your own library: ExportTable carries column widths, alignment, bands, frozen columns, group levels and a number format per cell (Export).
  • Awaiting the job gives ExportOutcome; into_output() is the closure’s value.

The repository’s gateway (crates/grid-gateway) serves simulated quotes over a WebSocket with the small binary protocol of crates/grid-feed: a snapshot, then deltas conflated per client to the latest value per key, and a heartbeat every second. FeedClient is the protocol’s state machine, free of any socket; this recipe drives it from a thread with tungstenite and writes what it says into the grid.

/// Feeds the grid from the quotes gateway over a WebSocket: the first
/// snapshot replaces every row, each delta in order is one batch, and a gap
/// asks the server for a new snapshot. A lost connection comes back after
/// 1 s, doubling up to 30 s; the thread ends with the grid.
fn feed_from_gateway(writer: GridWriter<Quote>, address: String) {
std::thread::spawn(move || {
let mut client = FeedClient::new();
let mut backoff = Duration::from_secs(1);
while !writer.is_closed() {
if let Ok((mut socket, _)) = tungstenite::connect(address.as_str()) {
backoff = Duration::from_secs(1);
let mut open = socket
.send(Message::binary(client.on_open().to_bytes()))
.is_ok();
// The server sends a heartbeat every second, so this loop
// sees a closed writer soon after the window closes.
while open && !writer.is_closed() {
let Ok(message) = socket.read() else { break };
let Message::Binary(bytes) = message else {
continue;
};
let action = match ServerMessage::decode(&bytes) {
Ok(message) => client.on_message(message),
Err(_) => client.on_malformed(),
};
match action {
Some(FeedAction::ReplaceAll(rows)) => {
writer.replace_all(rows.into_iter().map(|row| Arc::new(Quote(row))));
}
Some(FeedAction::Batch { upserts, removes }) => writer.batch(|batch| {
upserts.into_iter().for_each(|row| batch.upsert(Quote(row)));
removes.into_iter().for_each(|id| batch.remove(id));
}),
Some(FeedAction::Send(message)) => {
open = socket.send(Message::binary(message.to_bytes())).is_ok();
}
None => {}
}
}
client.on_close();
}
std::thread::sleep(backoff);
backoff = (backoff * 2).min(Duration::from_secs(30));
}
});
}
  • A snapshot replaces every row; each delta is one batch, so it lands in one snapshot.
  • After a sequence gap the client asks for a resync once and drops deltas until a new snapshot covers the gap.
  • The reload API is not needed here: the snapshot and the deltas arrive on one ordered stream, so there is nothing to replay.

Run the gateway, then the example against it:

Terminal window
cargo run --release -p gpui-kit-grid-gateway
COOKBOOK_FEED=ws://127.0.0.1:9001 cargo run --release -p gpui-kit-grid --example cookbook

The browser demos use the same protocol over the browser’s WebSocket (Web).

Two views of the same live rows: every quote by price in one grid, by volume in another. The store ingests each write once; each grid sorts, filters, groups and highlights on its own.

/// Two views of one live store: every quote by price in the main grid, and
/// the same quotes by volume beside it. The store ingests each write once;
/// each grid sorts, filters and highlights on its own.
fn two_grids(layout_path: PathBuf, cx: &mut App) -> (QuotesGrid, QuotesGrid, GridWriter<Quote>) {
let store = GridStore::builder(StoreOptions::default()).build(cx);
let writer = store.writer();
let main = grid_with_saved_layout(store.clone(), layout_path, cx);
let by_volume = cx.new(|cx| {
LiveGridState::new(schema(), Quotes, store, cx)
.with_view(
ViewSpec::new().with_sort([(QuoteColumn::Volume, SortDirection::Descending)]),
)
.with_change_paint(ChangePaint::Custom(Arc::new(OnlyColumn(
QuoteColumn::Volume.ix(),
))))
});
(main, by_volume, writer)
}

The second grid flashes only its Volume column, with a change painter of its own:

/// Paints changes as the default painter does, but an update only in one
/// column: a new or removed row still flashes whole. Runs in prepaint for
/// every changed visible cell, so it neither allocates nor locks.
struct OnlyColumn(ColumnIx);
impl ChangePainter for OnlyColumn {
fn paint(&self, change: &CellChange, palette: &ChangePalette, style: &mut CellStyle) {
if change.change() != RowChange::Updated || change.column_ix() == Some(self.0) {
UniformChanges.paint(change, palette, style);
}
}
}

A painter runs in prepaint for every changed visible cell, so it must not allocate or lock; cell_style still runs after it and has the last word.

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