Table
Records in rows and columns.
Supports drag and drop. See the shared DnD guide for lifecycle, callbacks, and transaction rules.
Try it
Select a task in this eight-row table with Task, Owner, and Status columns. This preview runs the exact code sample shown on this page.
Basic use
use xgpui_luma_look_radix as radix;
use gpui::{Context, ParentElement};
use xgpui_luma::controls::table::TableSelectionMode;
#[derive(Clone)]
struct Task {
title: &'static str,
owner: &'static str,
status: &'static str,
}
struct Example {
control: gpui::Entity<xgpui_luma::controls::table::TableControl<Task>>,
selected_rows: Vec<usize>,
}
impl Example {
fn new(cx: &mut Context<Self>) -> Self {
let look = cx.try_global::<radix::Look>().cloned().unwrap_or_else(radix::Look::built_in);
let control = radix::table_builder::<Task>(&look, "records")
.items([
Task { title: "Build navigation", owner: "Alex", status: "Done" },
Task { title: "Review inputs", owner: "Sam", status: "In progress" },
Task { title: "Add chart examples", owner: "Jamie", status: "Done" },
Task { title: "Test keyboard focus", owner: "Alex", status: "Open" },
Task { title: "Polish color picker", owner: "Sam", status: "In progress" },
Task { title: "Write API guide", owner: "Jamie", status: "Open" },
Task { title: "Audit contrast", owner: "Alex", status: "Blocked" },
Task { title: "Publish documentation", owner: "Sam", status: "Open" },
])
.row_label(|row| row.title)
.grid_columns()
.column_fill("Task", |row: &Task| gpui::div().child(row.title))
.column_fixed("Owner", 90.0, |row: &Task| gpui::div().child(row.owner))
.column_fixed("Status", 120.0, |row: &Task| gpui::div().child(row.status))
.finish()
.column_resizing(true)
.selection_mode(TableSelectionMode::Single)
.on_selection_changed(cx, |app, selected_indices, cx| {
app.selected_rows = selected_indices.clone();
cx.notify();
})
.spawn(cx);
Self { control, selected_rows: Vec::new() }
}
}Integration snippet, checked against the current SDK and Radix crates. cx is the initialized owner context. The control retains its callbacks; callback owners are held weakly. Render self.control.clone() in the owner’s view.
Radix styling
table_builder(&look, id) styles the SDK shell, header, rows, selected/focused/disabled states, and drag feedback. Compose it with paging_table_builder(table, &look, pager_id) for a Radix Pager. Native virtualization and scrolling stay in the SDK; custom visible scrollbars can use scrollbar_template(&look).
Pass an explicit Look, including forks. Theme adapters retain that binding; notify affected controls and their owners after palette, mode, or stylesheet edits. Resolved look values are snapshots and must be resolved again during render.
See the Radix control support table for adapter and utility status.
SDK behavior & customization
SDK selection supports Single, Multiple, Extended, and None. Stable row keys matter for reordering and selection.
Relevant contracts: TableColumnRenderModel, TableRenderModel, TableHeaderTemplate, TableRowRenderModel.
Use the module’s template contract for structural changes. Builder modifiers and item templates vary by control.
Events
Callbacks follow the existing SDK event stream. Registrations append, hold the owner weakly, and live until the control is released. cx.notify() requests an owner redraw. Compound payloads are borrowed; scalar values are passed by value.
| Event / payload | Callback |
|---|---|
RowDrag(TableRowDragEvent) | .on_row_drag(cx, |app, event, cx| { … }) |
SelectionChanged { selected_indices: Vec<usize> } | .on_selection_changed(cx, |app, selected_indices, cx| { … }) |
SelectedKeysChanged { selected_keys: Vec<SharedString> } | .on_selected_keys_changed(cx, |app, selected_keys, cx| { … }) |
ActiveIndexChanged { active_index: Option<usize> } | .on_active_index_changed(cx, |app, active_index, cx| { … }) |
PageChanged { page: usize } | .on_page_changed(cx, |app, page, cx| { … }) |
PageSizeChanged { page_size: usize } | .on_page_size_changed(cx, |app, page_size, cx| { … }) |
ScrollChanged { top_index: usize } | .on_scroll_changed(cx, |app, top_index, cx| { … }) |
FocusChanged { focused: bool } | .on_focus_changed(cx, |app, focused, cx| { … }) |
RowHoverChanged { index: usize, hovered: bool } | .on_row_hover_changed(cx, |app, index, hovered, cx| { … }) |
EnabledChanged { enabled: bool } | .on_enabled_changed(cx, |app, enabled, cx| { … }) |
Use .on_event(cx, |app, event, cx| { … }) for all events. Factory-created controls support the same callbacks through TableEventExt. Existing cx.subscribe observers remain supported.
Drag/drop
See Drag and drop for the lifecycle, proposal/completion distinction, ordering, scopes, and callback ownership shared by these controls.
Set unique stable row keys with set_row_key, then enable set_row_reordering(true, cx). Table commits automatically; set_row_drop_handler overrides that transaction and must synchronously call commit_row_drop or reject_row_drop. Failed validation leaves the dataset unchanged.
SDK TableBuilder, PagingTableBuilder, TableEventExt (plain and paging entities), and Shadcn Table expose the shared named helpers. Their exact arguments are in the shared payload table with K=SharedString. The aggregate payload TableRowDragEvent aliases DragDropEvent<EntityId, SharedString>, nested in TableEvent::RowDrag; on_drag_drop is a compatible alias of on_row_drag.
Table currently supports same-table row reordering. Transfer-phase helpers filter the shared event type but do not enable cross-table transfer. Paging forwards the inner table events, whose source/target IDs identify that inner table.
Validate proposals and observe completion
This integration example is compiled by scripts/check_control_docs.py.
mod drag_drop_example {
use gpui::{Context, Entity};
use xgpui_luma::controls::table::{TableBuilder, TableControl, TableReorderError, TableSelectionError};
#[derive(Default)]
struct Queue { dirty: bool, error: Option<String> }
fn build(cx: &mut Context<Queue>) -> Result<Entity<TableControl<String>>, String> {
let control = TableBuilder::new_typed("queue")
.items(["Draft".to_string(), "Published".to_string(), "Review".to_string()])
.row_label(|row| row.clone())
.on_dropped(cx, |owner, _, _, _, _, changed, cx| {
owner.dirty |= changed;
owner.error = None;
cx.notify();
})
.on_drop_rejected(cx, |owner, _, _, _, _, error, cx| {
owner.error = Some(error.to_string());
cx.notify();
})
.spawn(cx);
control.update(cx, |table, cx| -> Result<(), String> {
table.set_row_key(|row| row.clone(), cx).map_err(|error: TableSelectionError| error.to_string())?;
table.set_row_reordering(true, cx).map_err(|error: TableReorderError| error.to_string())?;
table.set_row_drop_handler(|table, proposal, _, cx| {
if proposal.keys().iter().any(|key| key.as_ref() == "Published") {
table.reject_row_drop(proposal, "Published rows are locked", cx);
} else {
// Failed validation already emits rejection. No second completion is needed.
let _ = table.commit_row_drop(proposal, cx);
}
});
Ok(())
})?;
Ok(control)
}
}
Committed changes also support shared drop-layout motion, including duration settings, reduced motion and documented virtualization/transfer limits.