Data Table
A comprehensive data table component designed for handling large datasets with high performance. Features virtual scrolling, column configuration, sorting, filtering, row/column/cell selection, and custom cell rendering. Perfect for displaying tabular data with thousands of rows while maintaining smooth performance.
#Key Features
- Multiple Selection Modes: Row, column, and individual cell selection
- Virtual Scrolling: Handle thousands of rows with smooth performance
- Column Management: Resizable, movable, and fixed columns
- Sorting: Built-in column sorting support
- Keyboard Navigation: Full keyboard support for all selection modes
- Custom Cell Rendering: Render any content in table cells
- Context Menus: Right-click support for rows and cells
- Infinite Loading: Load more data as user scrolls
- Events: Comprehensive event system for user interactions
#Import
use gpui_kit::component::table::{
DataTable, TableState, TableDelegate,
Column, ColumnSort, ColumnFixed,
TableEvent
};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { ColumnSort, DataTableColumn, DataTableCell, DataTableRow, TableSort, DataTable } from "data-table.slint";use iced::widget::{column, container, mouse_area, row, svg, text};
use iced::{Alignment, Element, Length, Task, Theme};#Usage
#Invoices
// The component's own API, written out once: typed columns, typed rows, and
// the two handlers. The app keeps the sort and the selection, so the header
// arrow and the selected row are the app's state rather than the table's.
let basic_columns = vec![
Column::new("invoice", "Invoice").width(100.0).sortable(),
Column::new("status", "Status").width(100.0).sortable(),
Column::new("amount", "Amount").align(Align::End).sortable(),
];
let basic_rows = rows_for(INVOICES, app.sorts[section::INVOICES]);
let mut invoiced = DataTable::new()
.columns(basic_columns)
.rows(basic_rows)
.selected(app.selected[section::INVOICES])
.height(Length::Fixed(208.0))
.on_sort(|column, direction| Message::Sort {
table: section::INVOICES,
column,
direction,
})
.on_select(|index| Message::Select {
table: section::INVOICES,
index,
});
if let Some((column, direction)) = app.sorts[section::INVOICES] {
invoiced = invoiced.sort(column, direction);
}
let invoices = invoiced;To create a table, you need to implement the TableDelegate trait and provide column definitions, and use TableState to manage the table state.
use std::ops::Range;
use gpui_kit::{App, Context, Window, IntoElement};
use gpui_kit::component::table::{DataTable, TableDelegate, Column, ColumnSort};
struct MyData {
id: usize,
name: String,
age: u32,
email: String,
}
struct MyTableDelegate {
data: Vec<MyData>,
columns: Vec<Column>,
}
impl MyTableDelegate {
fn new() -> Self {
Self {
data: vec![
MyData { id: 1, name: "John".to_string(), age: 30, email: "john@example.com".to_string() },
MyData { id: 2, name: "Jane".to_string(), age: 25, email: "jane@example.com".to_string() },
],
columns: vec![
Column::new("id", "ID").width(60.),
Column::new("name", "Name").width(150.).sortable(),
Column::new("age", "Age").width(80.).sortable(),
Column::new("email", "Email").width(200.),
],
}
}
}
impl TableDelegate for MyTableDelegate {
fn columns_count(&self, _: &App) -> usize {
self.columns.len()
}
fn rows_count(&self, _: &App) -> usize {
self.data.len()
}
fn column(&self, col_ix: usize, _: &App) -> Column {
self.columns[col_ix].clone()
}
fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, _: &mut Context<TableState<Self>>) -> impl IntoElement {
let row = &self.data[row_ix];
let col = &self.columns[col_ix];
match col.key.as_ref() {
"id" => row.id.to_string(),
"name" => row.name.clone(),
"age" => row.age.to_string(),
"email" => row.email.clone(),
_ => "".to_string(),
}
}
}
// Create the table
let delegate = MyTableDelegate::new();
let state = cx.new(|cx| TableState::new(delegate, window, cx));DataTable {
height: 240px;
columns: [
{ key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
];
rows: [
root.invoice("INV001", "Paid", Status.success, "$250"),
root.invoice("INV002", "Pending", Status.warning, "$150"),
root.invoice("INV003", "Unpaid", Status.danger, "$350"),
root.invoice("INV004", "Paid", Status.success, "$90"),
root.invoice("INV005", "Pending", Status.warning, "$1,200"),
];
}#Column Configuration
// A column's width, alignment and sortability are its own: a fixed ID, a
// sortable name, an end-aligned age, and an email taking what is left. A
// header without `sortable` draws no indicator and no hover.
let configuration = body_box(
data_table(
app,
section::CONFIGURATION,
vec![
Column::new("id", "ID").width(64.0),
Column::new("name", "Name").width(140.0).sortable(),
Column::new("age", "Age").width(72.0).align(Align::End).sortable(),
Column::new("email", "Email"),
],
PEOPLE,
)
.height(Length::Fixed(150.0)),
);Columns provide extensive configuration options:
// Basic column
Column::new("id", "ID")
// Sortable column
Column::new("name", "Name")
.sortable()
.width(150.)
// Right-aligned column
Column::new("price", "Price")
.text_right()
.sortable()
// Fixed column (pinned to left)
Column::new("actions", "Actions")
.fixed(ColumnFixed::Left)
.resizable(false)
.movable(false)
// Column with custom padding
Column::new("description", "Description")
.width(200.)
.paddings(px(8.))
// Non-resizable column
Column::new("status", "Status")
.width(100.)
.resizable(false)
// Custom sort orders
Column::new("created", "Created")
.ascending() // Default ascending
// or
Column::new("modified", "Modified")
.descending() // Default descendingDataTable {
height: 180px;
columns: [
{ key: "id", title: "ID", width: 64px, align: TextHorizontalAlignment.left, sortable: false, resizable: false },
{ key: "name", title: "Name", width: 140px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
{ key: "age", title: "Age", width: 72px, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
{ key: "email", title: "Email", width: 0, align: TextHorizontalAlignment.left, sortable: false, resizable: true },
];
rows: [
{
id: "1",
cells: [
root.cell("1", Status.default),
root.cell("Ada Lovelace", Status.default),
root.cell("36", Status.default),
root.cell("ada@uni.dev", Status.default)
]
},
{
id: "2",
cells: [
root.cell("2", Status.default),
root.cell("Alan Turing", Status.default),
root.cell("41", Status.default),
root.cell("alan@uni.dev", Status.default)
]
},
{
id: "3",
cells: [
root.cell("3", Status.default),
root.cell("Grace Hopper", Status.default),
root.cell("85", Status.default),
root.cell("grace@uni.dev", Status.default)
]
},
];
}#Virtual Scrolling for Large Datasets
// Slint's body instantiates only the visible rows, so thousands stay cheap.
// iced's `scrollable` lays out and draws every row it is given, on every
// pass; this bounded body scrolls all sixteen, and the module names the gap
// rather than pretending a windowing layer exists.
let virtual_scrolling = body_box(
data_table(app, section::VIRTUAL, invoice_columns(), VIRTUAL).height(Length::Fixed(190.0)),
);The table automatically handles virtual scrolling for optimal performance:
struct LargeDataDelegate {
data: Vec<Record>, // Could be 10,000+ items
columns: Vec<Column>,
}
impl TableDelegate for LargeDataDelegate {
fn rows_count(&self, _: &App) -> usize {
self.data.len() // No performance impact regardless of size
}
// Only visible rows are rendered
fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, _: &mut Context<TableState<Self>>) -> impl IntoElement {
// This is only called for visible rows
// Efficiently render cell content
let row = &self.data[row_ix];
format_cell_data(row, col_ix)
}
// Track visible range for optimizations
fn visible_rows_changed(&mut self, visible_range: Range<usize>, _: &mut Window, _: &mut Context<TableState<Self>>) {
// Only update data for visible rows if needed
// This is called when user scrolls
}
}DataTable {
height: 220px;
columns: [
{ key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
];
rows: [
root.invoice("INV001", "Paid", Status.success, "$250"),
root.invoice("INV002", "Pending", Status.warning, "$150"),
root.invoice("INV003", "Unpaid", Status.danger, "$350"),
root.invoice("INV004", "Paid", Status.success, "$90"),
root.invoice("INV005", "Pending", Status.warning, "$1,200"),
root.invoice("INV006", "Paid", Status.success, "$480"),
root.invoice("INV007", "Unpaid", Status.danger, "$75"),
root.invoice("INV008", "Paid", Status.success, "$2,040"),
root.invoice("INV009", "Pending", Status.warning, "$610"),
root.invoice("INV010", "Paid", Status.success, "$128"),
root.invoice("INV011", "Unpaid", Status.danger, "$990"),
root.invoice("INV012", "Pending", Status.warning, "$340"),
root.invoice("INV013", "Paid", Status.success, "$1,760"),
root.invoice("INV014", "Unpaid", Status.danger, "$55"),
root.invoice("INV015", "Paid", Status.success, "$870"),
root.invoice("INV016", "Pending", Status.warning, "$430"),
];
}#Sorting Implementation
// Sorting stays with the application, which is the Slint `sort-changed`
// branch: the header reports the direction the cycle lands on and the caller
// reorders. `rows_for` is that order function, reading numbers by value so
// `$1,200` sorts above `$90`.
let sorting = body_box(
data_table(
app,
section::SORTING,
vec![
Column::new("name", "Name").width(160.0).sortable(),
Column::new("age", "Age").width(72.0).align(Align::End).sortable(),
Column::new("email", "Email").sortable(),
],
NAMES,
)
.height(Length::Fixed(170.0)),
);Implement sorting in your delegate:
impl TableDelegate for MyTableDelegate {
fn perform_sort(&mut self, col_ix: usize, sort: ColumnSort, _: &mut Window, _: &mut Context<TableState<Self>>) {
let col = &self.columns[col_ix];
match col.key.as_ref() {
"name" => {
match sort {
ColumnSort::Ascending => self.data.sort_by(|a, b| a.name.cmp(&b.name)),
ColumnSort::Descending => self.data.sort_by(|a, b| b.name.cmp(&a.name)),
ColumnSort::Default => {
// Reset to original order or default sort
self.data.sort_by(|a, b| a.id.cmp(&b.id));
}
}
}
"age" => {
match sort {
ColumnSort::Ascending => self.data.sort_by(|a, b| a.age.cmp(&b.age)),
ColumnSort::Descending => self.data.sort_by(|a, b| b.age.cmp(&a.age)),
ColumnSort::Default => self.data.sort_by(|a, b| a.id.cmp(&b.id)),
}
}
_ => {}
}
}
}DataTable {
height: 200px;
columns: [
{ key: "name", title: "Name", width: 160px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "age", title: "Age", width: 72px, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
{ key: "email", title: "Email", width: 0, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
];
rows: [
{
id: "cara",
cells: [
root.cell("Cara", Status.default),
root.cell("41", Status.default),
root.cell("cara@uni.dev", Status.default)
]
},
{
id: "bob",
cells: [
root.cell("Bob", Status.default),
root.cell("22", Status.default),
root.cell("bob@uni.dev", Status.default)
]
},
{
id: "ada",
cells: [
root.cell("Ada", Status.default),
root.cell("36", Status.default),
root.cell("ada@uni.dev", Status.default)
]
},
{
id: "dan",
cells: [
root.cell("Dan", Status.default),
root.cell("27", Status.default),
root.cell("dan@uni.dev", Status.default)
]
},
];
}#ContextMenu
impl TableDelegate for MyTableDelegate {
// Context menu for right-click
fn context_menu(&mut self, row_ix: usize, menu: PopupMenu, _: &mut Window, _: &mut Context<TableState<Self>>) -> PopupMenu {
let row = &self.data[row_ix];
menu.menu(format!("Edit {}", row.name), Box::new(EditRowAction(row_ix)))
.menu("Delete", Box::new(DeleteRowAction(row_ix)))
.separator()
.menu("Duplicate", Box::new(DuplicateRowAction(row_ix)))
}
}#Cell Rendering
// A cell's status colours its text — `Theme.status_color` in the Slint
// component — so a gain reads green and a loss red without a badge in the
// row.
let cells = body_box(
data_table(
app,
section::CELLS,
vec![
Column::new("symbol", "Symbol").width(100.0).sortable(),
Column::new("side", "Side").width(80.0),
Column::new("pnl", "P&L").align(Align::End).sortable(),
],
TRADES,
)
.height(Length::Fixed(150.0)),
);Create rich cell content with custom rendering:
impl TableDelegate for MyTableDelegate {
fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context<TableState<Self>>) -> impl IntoElement {
let row = &self.data[row_ix];
let col = &self.columns[col_ix];
match col.key.as_ref() {
"status" => {
// Custom status badge
let (color, text) = match row.status {
Status::Active => (cx.theme().green, "Active"),
Status::Inactive => (cx.theme().red, "Inactive"),
Status::Pending => (cx.theme().yellow, "Pending"),
};
div()
.px_2()
.py_1()
.rounded(px(4.))
.bg(color.opacity(0.1))
.text_color(color)
.child(text)
}
"progress" => {
// Progress bar
div()
.w_full()
.h(px(8.))
.bg(cx.theme().muted)
.rounded(px(4.))
.child(
div()
.h_full()
.w(percentage(row.progress))
.bg(cx.theme().primary)
.rounded(px(4.))
)
}
"actions" => {
// Action buttons
h_flex()
.gap_1()
.child(Button::new(format!("edit-{}", row_ix)).text().icon(IconName::Edit))
.child(Button::new(format!("delete-{}", row_ix)).text().icon(IconName::Trash))
}
"avatar" => {
// User avatar with image
h_flex()
.items_center()
.gap_2()
.child(
div()
.w(px(32.))
.h(px(32.))
.rounded_full()
.bg(cx.theme().accent)
.flex()
.items_center()
.justify_center()
.child(row.name.chars().next().unwrap_or('?').to_string())
)
.child(row.name.clone())
}
_ => row.get_field_value(col.key.as_ref()).into_any_element(),
}
}
}DataTable {
height: 180px;
columns: [
{ key: "symbol", title: "Symbol", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "side", title: "Side", width: 80px, align: TextHorizontalAlignment.left, sortable: false, resizable: false },
{ key: "pnl", title: "P&L", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
];
rows: [
{
id: "aapl",
cells: [
root.cell("AAPL", Status.default),
root.cell("Long", Status.info),
root.cell("+2.40%", Status.success)
]
},
{
id: "nvda",
cells: [
root.cell("NVDA", Status.default),
root.cell("Short", Status.warning),
root.cell("-1.10%", Status.danger)
]
},
{
id: "tsla",
cells: [
root.cell("TSLA", Status.default),
root.cell("Long", Status.info),
root.cell("+3.18%", Status.success)
]
},
{
id: "amzn",
cells: [
root.cell("AMZN", Status.default),
root.cell("Short", Status.warning),
root.cell("-0.42%", Status.danger)
]
},
];
}#Selection Modes
// The table is selectable whenever the caller hands it an `on_select`; the
// selected index is drawn on the selection token, which is the Slint
// `Theme.selection` at 0.15 in the light theme and 0.2 in the dark one.
let selection_line_below = selection_line(
app,
section::SELECTION,
"Selected row ",
"No row selected",
false,
);
let selection = body_box(column![
data_table(app, section::SELECTION, invoice_columns(), INVOICES).height(Length::Fixed(150.0)),
selection_line_below,
]
.spacing(theme::SPACING_SM));The table supports three distinct selection modes:
// Row selection mode (default)
let state = cx.new(|cx| {
TableState::new(delegate, window, cx)
.row_selectable(true) // Enable row selection
.col_selectable(false)
.cell_selectable(false)
});
// Column selection mode
let state = cx.new(|cx| {
TableState::new(delegate, window, cx)
.row_selectable(false)
.col_selectable(true) // Enable column selection
.cell_selectable(false)
});
// Cell selection mode
let state = cx.new(|cx| {
TableState::new(delegate, window, cx)
.row_selectable(true) // Keep row selection for row selector column
.col_selectable(false)
.cell_selectable(true) // Enable cell selection
});VerticalLayout {
spacing: 8px;
selectable := DataTable {
height: 180px;
row-selectable: true;
columns: [
{ key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
];
rows: [
root.invoice("INV001", "Paid", Status.success, "$250"),
root.invoice("INV002", "Pending", Status.warning, "$150"),
root.invoice("INV003", "Unpaid", Status.danger, "$350"),
root.invoice("INV004", "Paid", Status.success, "$90"),
];
}
Text {
text: selectable.selected-row < 0 ? "No row selected" : "Selected row \{selectable.selected-row + 1}";
color: Theme.muted-foreground;
font-size: Theme.font-size-sm;
}
}#Reading and Writing the Selection
// The selection is one read and one write on the caller's side: a button can
// set it and clear it without the table knowing, which is the Slint
// `selected-row` property.
let selection_controls = row![
Button::new("Select row 3")
.outline()
.on_press(Message::Select {
table: section::SELECTION_API,
index: 2,
}),
Button::new("Clear")
.outline()
.on_press(Message::ClearSelection(section::SELECTION_API)),
]
.spacing(theme::SPACING_SM);
let selection_api_line =
selection_line(app, section::SELECTION_API, "selected_row: ", "selected_row: none", true);
let selection_api = body_box(column![
selection_controls,
data_table(app, section::SELECTION_API, invoice_columns(), INVOICES)
.height(Length::Fixed(150.0)),
selection_api_line,
]
.spacing(theme::SPACING_SM));A table holds one selection at a time: nothing, a row, a column, or a cell.
selection() returns it as a single TableSelection value, and
set_selection() writes one back, so persisting and restoring the selection
is one read and one write:
use gpui_component::table::TableSelection;
match state.read(cx).selection() {
TableSelection::None => {}
TableSelection::Row(row_ix) => println!("Row {row_ix}"),
TableSelection::Column(col_ix) => println!("Column {col_ix}"),
TableSelection::Cell(row_ix, col_ix) => println!("Cell ({row_ix}, {col_ix})"),
}
state.update(cx, |state, cx| {
// Scrolls and emits exactly as `set_selected_cell(5, 3, cx)` would.
state.set_selection(TableSelection::Cell(5, 3), cx);
});VerticalLayout {
spacing: 8px;
HorizontalLayout {
spacing: 8px;
Button {
text: "Select row 3";
outline: true;
clicked => {
sheet.selected-row = 2;
}
}
Button {
text: "Clear";
outline: true;
clicked => {
sheet.clear-selection();
}
}
}
sheet := DataTable {
height: 180px;
columns: [
{ key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
];
rows: [
root.invoice("INV001", "Paid", Status.success, "$250"),
root.invoice("INV002", "Pending", Status.warning, "$150"),
root.invoice("INV003", "Unpaid", Status.danger, "$350"),
root.invoice("INV004", "Paid", Status.success, "$90"),
];
}
Text {
text: sheet.selected-row < 0 ? "selected_row: none" : "selected_row: \{sheet.selected-row}";
color: Theme.muted-foreground;
font-size: Theme.font-size-sm;
}
}The positional getters selected_row(), selected_col() and selected_cell()
each answer only for their own kind of selection. selected_row() is Some
only when a row itself is selected; a selected cell does not surface through it,
and selecting a row clears what selected_cell() reports. When you need the row
a selected cell sits in, map it from the cell:
let row_ix = state.read(cx).selected_cell().map(|(row_ix, _)| row_ix);Keyboard navigation remembers the last row and column position across mode
changes. Pressing Down after selecting a column continues from the row that
was selected before, even though selected_row() returned None in column
mode.
#Column Resizing and Moving
// Slint drags a handle on the header and writes the new width back. iced has
// no drag delta to hand a controlled widget, so the width is what the caller
// sets: 120 and 120 here, and the amount taking the rest.
let resizing = body_box(
data_table(
app,
section::RESIZING,
vec![
Column::new("invoice", "Invoice").width(120.0).sortable(),
Column::new("status", "Status").width(120.0).sortable(),
Column::new("amount", "Amount").align(Align::End).sortable(),
],
INVOICES,
)
.height(Length::Fixed(150.0)),
);Enable dynamic column management:
// Configure table features
let state = cx.new(|cx| {
TableState::new(delegate, window, cx)
.col_resizable(true) // Allow column resizing
.col_movable(true) // Allow column reordering
.sortable(true) // Enable sorting
.col_selectable(true) // Allow column selection
.row_selectable(true) // Allow row selection
});
// Listen for column changes
cx.subscribe_in(&state, window, |view, table, event, _, cx| {
match event {
TableEvent::ColumnWidthsChanged(widths) => {
// Save column widths to user preferences
save_column_widths(widths);
}
TableEvent::MoveColumn(from_ix, to_ix) => {
// Save column order
save_column_order(from_ix, to_ix);
}
_ => {}
}
}).detach();DataTable {
height: 180px;
col-resizable: true;
columns: [
{ key: "invoice", title: "Invoice", width: 120px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
{ key: "status", title: "Status", width: 120px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
{ key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
];
rows: [
root.invoice("INV001", "Paid", Status.success, "$250"),
root.invoice("INV002", "Pending", Status.warning, "$150"),
root.invoice("INV003", "Unpaid", Status.danger, "$350"),
];
}#Infinite Loading / Pagination
Implement loading more data as user scrolls:
impl TableDelegate for MyTableDelegate {
fn has_more(&self, _: &App) -> bool {
self.has_more_data
}
fn load_more_threshold(&self) -> usize {
50 // Load more when 50 rows from bottom
}
fn load_more(&mut self, _: &mut Window, cx: &mut Context<TableState<Self>>) {
if self.loading {
return; // Prevent multiple loads
}
self.loading = true;
// Spawn async task to load data
cx.spawn(async move |view, cx| {
let new_data = fetch_more_data().await;
cx.update(|cx| {
view.update(cx, |view, _| {
let delegate = view.table.delegate_mut();
delegate.data.extend(new_data);
delegate.loading = false;
delegate.has_more_data = !new_data.is_empty();
});
})
}).detach();
}
fn loading(&self, _: &App) -> bool {
self.loading
}
}#Table Styling
// The three appearance switches the Slint example offers: a size for the row
// height and the cell padding, `stripe` for the alternating tint, and
// `bordered` for the edge and the rounded corners.
let styling = body_box(
data_table(app, section::STYLING, invoice_columns(), INVOICES)
.size(ControlSize::Large)
.stripe(true)
.bordered(true)
.height(Length::Fixed(190.0)),
);Customize table appearance. DataTable implements Sizable: use preset sizes such as .small() and .large() for standard density, or pass a custom pixel size to set a uniform header and body row height.
use gpui_kit::px;
use gpui_kit::component::Sizable as _;
let state = cx.new(|cx| {
TableState::new(delegate, window, cx)
});
// In render
DataTable::new(&state)
.with_size(px(48.)) // Custom uniform row height
.stripe(true) // Alternating row colors
.bordered(true) // Border around table
.scrollbar_visible(true, true) // Vertical, horizontal scrollbarsDataTable {
height: 220px;
size: ControlSize.large;
stripe: true;
bordered: true;
columns: [
{ key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
];
rows: [
root.invoice("INV001", "Paid", Status.success, "$250"),
root.invoice("INV002", "Pending", Status.warning, "$150"),
root.invoice("INV003", "Unpaid", Status.danger, "$350"),
root.invoice("INV004", "Paid", Status.success, "$90"),
];
}#Financial Data Table
struct StockData {
symbol: String,
price: f64,
change: f64,
change_percent: f64,
volume: u64,
}
impl TableDelegate for StockTableDelegate {
fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context<TableState<Self>>) -> impl IntoElement {
let stock = &self.stocks[row_ix];
let col = &self.columns[col_ix];
match col.key.as_ref() {
"symbol" => div().font_weight(FontWeight::BOLD).child(stock.symbol.clone()),
"price" => div().text_right().child(format!("${:.2}", stock.price)),
"change" => {
let color = if stock.change >= 0.0 { cx.theme().green } else { cx.theme().red };
div()
.text_right()
.text_color(color)
.child(format!("{:+.2}", stock.change))
}
"change_percent" => {
let color = if stock.change_percent >= 0.0 { cx.theme().green } else { cx.theme().red };
div()
.text_right()
.text_color(color)
.child(format!("{:+.1}%", stock.change_percent * 100.0))
}
"volume" => div().text_right().child(format!("{:,}", stock.volume)),
_ => div(),
}
}
}DataTable {
height: 280px;
stripe: true;
col-resizable: true;
columns: [
{ key: "symbol", title: "Symbol", width: 88px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
{ key: "name", title: "Name", width: 180px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
{ key: "price", title: "Price", width: 88px, align: TextHorizontalAlignment.right, sortable: true, resizable: true },
{ key: "change", title: "Chg", width: 80px, align: TextHorizontalAlignment.right, sortable: true, resizable: true },
{ key: "volume", title: "Volume", width: 120px, align: TextHorizontalAlignment.right, sortable: true, resizable: true },
{ key: "market_cap", title: "Market Cap", width: 120px, align: TextHorizontalAlignment.right, sortable: true, resizable: true },
];
rows: [
root.quote("AAPL", "Apple Inc.", "228.52", "+1.24", true, "52,430,100", "3,480,000"),
root.quote("NVDA", "NVIDIA Corporation", "134.81", "-0.62", false, "180,220,400", "3,310,000"),
root.quote("TSLA", "Tesla, Inc.", "251.44", "+3.18", true, "98,110,250", "802,000"),
root.quote("MSFT", "Microsoft Corporation", "428.02", "+0.41", true, "22,540,800", "3,180,000"),
root.quote("AMZN", "Amazon.com, Inc.", "186.33", "-1.07", false, "41,220,600", "1,950,000"),
root.quote("700.HK", "Tencent Holdings", "412.60", "+0.87", true, "18,440,200", "3,820,000"),
];
}// Six columns of quotes, the change in the success and danger colours, and
// the stripe the Slint example asks for on a wide table.
let financial = body_box(
data_table(
app,
section::FINANCIAL,
vec![
Column::new("symbol", "Symbol").width(88.0).sortable(),
Column::new("name", "Name").width(180.0).sortable(),
Column::new("price", "Price").width(88.0).align(Align::End).sortable(),
Column::new("change", "Chg").width(80.0).align(Align::End).sortable(),
Column::new("volume", "Volume").width(120.0).align(Align::End).sortable(),
Column::new("market_cap", "Market Cap")
.width(120.0)
.align(Align::End)
.sortable(),
],
QUOTES,
)
.stripe(true)
.height(Length::Fixed(250.0)),
);#User Management Table
struct UserTableDelegate {
users: Vec<User>,
columns: Vec<Column>,
}
impl UserTableDelegate {
fn new() -> Self {
Self {
users: Vec::new(),
columns: vec![
Column::new("avatar", "").width(50.).resizable(false).movable(false),
Column::new("name", "Name").width(150.).sortable().fixed_left(),
Column::new("email", "Email").width(200.).sortable(),
Column::new("role", "Role").width(100.).sortable(),
Column::new("status", "Status").width(100.),
Column::new("last_login", "Last Login").width(120.).sortable(),
Column::new("actions", "Actions").width(100.).resizable(false),
],
}
}
}DataTable {
height: 240px;
columns: [
{ key: "name", title: "Name", width: 160px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
{ key: "email", title: "Email", width: 180px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
{ key: "role", title: "Role", width: 90px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
{ key: "last_login", title: "Last Login", width: 0, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
];
rows: [
root.person("Ada Lovelace", "ada@uni.dev", "Admin", "Active", Status.success, "2026-03-02"),
root.person("Grace Hopper", "grace@uni.dev", "Editor", "Invited", Status.info, "2026-01-18"),
root.person("Alan Turing", "alan@uni.dev", "Viewer", "Suspended", Status.danger, "2025-11-04"),
root.person("Katherine Johnson", "katherine@uni.dev", "Editor", "Active", Status.success, "2026-02-11"),
root.person("Edsger Dijkstra", "edsger@uni.dev", "Admin", "Active", Status.success, "2025-12-20"),
];
}// The five-column people table, with the role and the status drawn from the
// same cell status the rest of the demo uses.
let users = body_box(
data_table(
app,
section::USERS,
vec![
Column::new("name", "Name").width(160.0).sortable(),
Column::new("email", "Email").width(180.0).sortable(),
Column::new("role", "Role").width(90.0).sortable(),
Column::new("status", "Status").width(100.0).sortable(),
Column::new("last_login", "Last Login").sortable(),
],
USERS,
)
.height(Length::Fixed(210.0)),
);#Cell Selection
Enable individual cell selection for more granular control:
let state = cx.new(|cx| {
TableState::new(delegate, window, cx)
.cell_selectable(true) // Enable cell selection
.row_selectable(true) // Also allow row selection
});
// Listen for cell events
cx.subscribe_in(&state, window, |view, table, event, _, cx| {
match event {
TableEvent::SelectCell(row_ix, col_ix) => {
println!("Selected cell: ({}, {})", row_ix, col_ix);
}
TableEvent::DoubleClickedCell(row_ix, col_ix) => {
// Open editor or detail view
open_cell_editor(row_ix, col_ix);
}
TableEvent::RightClickedCell(row_ix, col_ix) => {
// Show cell-specific context menu
show_cell_context_menu(row_ix, col_ix);
}
TableEvent::ClearSelection => {
println!("Selection cleared");
}
_ => {}
}
}).detach();#Cell Selection Features
When cell selection is enabled:
- Click to select: Click on any cell to select it
- Row selector column: A dedicated column appears on the left for selecting entire rows
- Keyboard navigation: Arrow keys navigate between cells (not rows/columns)
- Double-click support: Trigger actions like editing by double-clicking cells
- Right-click support: Show context menus specific to cell content
- Visual feedback: Selected cells show highlight with border
#Programmatic Cell Selection
// Get the currently selected cell. `None` while a row or column is
// selected instead; see "Reading and Writing the Selection" above.
if let Some((row_ix, col_ix)) = state.read(cx).selected_cell() {
println!("Current cell: ({}, {})", row_ix, col_ix);
}
// Select a specific cell programmatically
state.update(cx, |state, cx| {
state.set_selected_cell(5, 3, cx); // Select row 5, column 3
});
// Clear all selections
state.update(cx, |state, cx| {
state.clear_selection(cx);
});#Non-selectable Columns
Prevent specific columns from being selected (useful for action columns):
Column::new("actions", "Actions")
.width(100.)
.selectable(false) // This column's cells cannot be selected
.resizable(false)#Cell Selection with Custom Rendering
impl TableDelegate for MyTableDelegate {
fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context<TableState<Self>>) -> impl IntoElement {
let row = &self.data[row_ix];
let col = &self.columns[col_ix];
// Render different content based on whether cell is selected
let is_selected = cx.entity().read(cx).selected_cell() == Some((row_ix, col_ix));
match col.key.as_ref() {
"editable_field" => {
if is_selected {
// Show input when selected
Input::new(format!("cell-{}-{}", row_ix, col_ix))
.value(row.field_value.clone())
.into_any_element()
} else {
// Show plain text when not selected
div().child(row.field_value.clone()).into_any_element()
}
}
_ => div().child(row.get_value(col.key.as_ref())).into_any_element()
}
}
}#Keyboard Shortcuts
#Row Selection Mode (default)
↑/↓- Navigate rows←/→- Navigate columnsHome- Jump to first row/columnEnd- Jump to last row/columnPageUp/PageDown- Navigate by pageEscape- Clear selection
#Cell Selection Mode
↑/↓- Navigate up/down within current column←/→- Navigate left/right within current rowTab- Move to next cell (right, then next row)Shift+Tab- Move to previous cellHome- Jump to first cell in current rowEnd- Jump to last cell in current rowPageUp/PageDown- Navigate by page within current columnEscape- Clear selection
#API Reference
#Core Types
- DataTable - The data table component
- TableState - Table state management
- TableDelegate - Trait for implementing table data source
- Column - Column configuration
- TableEvent - Table events (selection, clicks, etc.)
- TableSelection - The current selection as one value:
None,Row,Column, orCell
#Column Types
- ColumnSort - Column sort direction enum
- ColumnFixed - Column fixed position enum
#Methods
#TableState
new(delegate, window, cx)- Create a new table statecell_selectable(bool)- Enable/disable cell selectionrow_selectable(bool)- Enable/disable row selectioncol_selectable(bool)- Enable/disable column selectionselection()- Get the current selection as aTableSelectionset_selection(selection, cx)- Set the selection from aTableSelection;TableSelection::Noneclears itselected_cell()- Get the selected cell;Noneunless a cell is selectedset_selected_cell(row_ix, col_ix, cx)- Select a specific cellselected_row()- Get the selected row;Noneunless a row itself is selectedselected_col()- Get the selected column;Noneunless a column itself is selectedclear_selection(cx)- Clear all selectionsscroll_to_row(row_ix, cx)- Scroll to specific rowscroll_to_col(col_ix, cx)- Scroll to specific column
#Column
new(key, name)- Create a new columnwidth(pixels)- Set column widthsortable()- Make column sortableascending()- Set default sort to ascendingdescending()- Set default sort to descendingtext_right()- Right-align column texttext_center()- Center-align column textfixed(ColumnFixed)- Pin column to leftresizable(bool)- Enable/disable column resizingmovable(bool)- Enable/disable column movingselectable(bool)- Enable/disable column/cell selectionpaddings(edges)- Set custom paddingmin_width(pixels)- Set minimum widthmax_width(pixels)- Set maximum width
#Events
SelectRow(usize)- Row selectedDoubleClickedRow(usize)- Row double-clickedSelectColumn(usize)- Column selectedSelectCell(usize, usize)- Cell selected (row_ix, col_ix)DoubleClickedCell(usize, usize)- Cell double-clicked (row_ix, col_ix)RightClickedCell(usize, usize)- Cell right-clicked (row_ix, col_ix)RightClickedRow(Option<usize>)- Row right-clickedColumnWidthsChanged(Vec<Pixels>)- Column widths changedMoveColumn(usize, usize)- Column moved (from_ix, to_ix)
#ColumnSort
default, ascending, descending
#DataTableColumn
A column of a `DataTable`. `width: 0` fills the remaining space (100px minimum); `sortable` shows the sort indicator and makes the header cycle Default → Descending → Ascending.
| Name | Kind | Type | Default |
|---|---|---|---|
key | field | string | |
title | field | string | |
width | field | length | |
align | field | TextHorizontalAlignment | |
sortable | field | bool | |
resizable | field | bool |
#DataTableCell
A cell value. `status` colors the text (e.g. gains and losses).
| Name | Kind | Type | Default |
|---|---|---|---|
text | field | string | |
status | field | Status |
#DataTableRow
| Name | Kind | Type | Default |
|---|---|---|---|
id | field | string | |
cells | field | [DataTableCell] |
#DataTable
A data table for large, interactive data sets. Mirrors GPUI Component's `DataTable`: typed columns and rows, a fixed header over a virtualized body (only the visible rows are instantiated, so thousands of rows stay cheap), sortable and resizable columns, row selection with the keyboard (Up/Down, Home/End, PageUp/PageDown, Escape), stripes, borders and sizes. A header click cycles Default → Descending → Ascending. When `TableSort` is installed and `row-order` is empty, the table reorders itself. Otherwise sorting stays with the application, as in GPUI's `perform_sort`: handle `sort-changed` and either reorder `rows` or pass the display order in `row-order` (indices into `rows`; empty means the natural order).
| Name | Kind | Type | Default | Description |
|---|---|---|---|---|
columns | in-out | [DataTableColumn] | ||
rows | in | [DataTableRow] | ||
row-order | in | [int] | ||
size | in | ControlSize | ControlSize.medium | |
stripe | in | bool | ||
bordered | in | bool | true | |
row-selectable | in | bool | true | |
col-resizable | in | bool | true | |
empty-text | in | string | "No data" | |
selected-row | in-out | int | -1 | The selected row as a display index, or -1. |
sort-column | in-out | int | -1 | |
sort | in-out | ColumnSort | ColumnSort.default | |
first-visible-row | out | int | first-row | |
row-count | out | int | row-order.length > 0 ? row-order.length : rows.length | |
sort-changed | callback | (/* column */ int, ColumnSort) | ||
row-selected | callback | (/* display index */ int) | ||
row-double-clicked | callback | (/* display index */ int) |