Sortable data table with stable identity
This complete Rust and Slint application connects column sorting, keyboard navigation and selection in a KitDataTable. Rust owns projects and their identities; the table virtualizes their presentation. The example uses Slint Kit 0.3.1, Rust 1.92+ and Slint 1.18.1.
Choose Atlas, then sort the Tasks column: Atlas stays selected even as it moves to another row. Cedar is archived and cannot be selected. An event from an old generation is rejected.
Try the application
Create a fresh project, then replace its files with the four complete files below. Packages come from crates.io; no kit checkout is needed. The native prerequisites match the getting-started guide.
cargo new slint-kit-table-example
cd slint-kit-table-example

- Click Atlas to select project
#101. - Click Tasks: values sort numerically, not as strings. A second click reverses sorting, and a third restores the original order.
- Selection stays on
#101after sorting. The token identifies the project, never its visual row position. - Focus the table, then use arrows, Home, End and Enter. Cedar is skipped. Page keys move focus by up to five enabled projects.
Why the generation changes
ProjectId is a stable domain key. ViewGeneration identifies the projected order. Every sort increments that generation; activate(token, generation) checks the generation, project existence and availability before accepting selection. The active row also follows a key. active-index is only a projection used for focus and scrolling.
The VecModel remains alive across publications. The table has a bounded viewport and uses the kit's virtualized view. The controller releases state borrows before model notifications or window updates; callbacks keep only weak window references. Four projects make the mechanism readable; for large datasets, also adapt host-side filtering and sorting.
Cargo.toml
Download this example — Cargo.toml
[package]
name = "slint-kit-table-example"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
license = "GPL-3.0-or-later"
publish = false
[dependencies]
slint-kit = "0.3"
slint = { version = "=1.18.1", default-features = false, features = ["std", "compat-1-18", "backend-winit", "renderer-skia", "accessibility"] }
[build-dependencies]
slint-kit-build = "0.3"
[workspace]
build.rs
Download this example — build.rs
// SPDX-License-Identifier: GPL-3.0-or-later
fn main() {
slint_kit_build::compile("ui/table.slint").expect("compile sortable table example");
}
ui/table.slint
Download this example — ui/table.slint
export { KitFormValidation, HtmlInputKind, NumberInputFormat, KitInputConstraints, KitValidationResult, KitValidityState } from "@kit/kit.slint";
// SPDX-License-Identifier: GPL-3.0-or-later
import { KitDataTable, KitTheme, KitThemes, TableRow, TableColumn, DataSortDirection, DataAlignment, NavigationStep } from "@kit/kit.slint";
export { TableRow, TableColumn, DataSortDirection, DataAlignment, NavigationStep }
export enum TableLanguage { english, french }
export enum TableAppearance { light, dark }
export component TableApp inherits Window {
in property <TableLanguage> language: TableLanguage.english;
in property <TableAppearance> appearance: TableAppearance.light;
private property <bool> french: root.language == TableLanguage.french;
in-out property <KitTheme> theme: root.appearance == TableAppearance.dark ? KitThemes.dark : KitThemes.light;
title: root.french ? "Tableau Slint Kit" : "Slint Kit table";
preferred-width: 720px;
preferred-height: 460px;
background: root.theme.canvas;
in property <[TableRow]> rows;
in property <[TableColumn]> columns;
in property <int> generation;
in property <int> active-index: -1;
in property <string> selection-label;
callback activate(int, int);
callback navigate(NavigationStep);
callback sort-requested(int);
public function focus-table() { table.take-focus(); }
VerticalLayout {
padding: 24px;
spacing: 16px;
Text { text: root.french ? "Projets" : "Projects"; color: root.theme.text-primary; font-size: 24px * root.theme.text-scale; font-weight: 600; }
Text { text: root.french ? "Triez les colonnes. Sélectionnez une ligne au clavier ou à la souris." : "Sort the columns. Select a row with the keyboard or pointer."; color: root.theme.text-secondary; font-size: 14px * root.theme.text-scale; wrap: word-wrap; }
table := KitDataTable {
theme: root.theme;
rows: root.rows;
columns: root.columns;
generation: root.generation;
active-index: root.active-index;
label: root.french ? "Projets" : "Projects";
activate(token, generation) => { root.activate(token, generation); }
navigate(step) => { root.navigate(step); }
sort-requested(token) => { root.sort-requested(token); }
}
Text { text: root.selection-label; color: root.theme.text-primary; font-size: 14px * root.theme.text-scale; wrap: word-wrap; }
}
}
src/main.rs
Download this example — src/main.rs
// SPDX-License-Identifier: GPL-3.0-or-later
use slint::{ComponentHandle, ModelRc, VecModel};
use std::{cell::RefCell, rc::Rc};
pub mod ui {
slint::include_modules!();
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ProjectId(i32);
#[derive(Clone, Copy, PartialEq, Eq)]
struct ViewGeneration(i32);
#[derive(Clone, Copy, PartialEq, Eq)]
enum Column {
Name,
Tasks,
}
impl Column {
fn from_token(token: i32) -> Option<Self> {
match token {
1 => Some(Self::Name),
2 => Some(Self::Tasks),
_ => None,
}
}
}
struct Project {
id: ProjectId,
name: &'static str,
tasks: u32,
archived: bool,
}
struct Catalogue {
projects: Vec<Project>,
order: Vec<ProjectId>,
selected: Option<ProjectId>,
active: Option<ProjectId>,
column: Column,
direction: ui::DataSortDirection,
generation: ViewGeneration,
}
impl Catalogue {
fn new() -> Self {
let projects = vec![
Project {
id: ProjectId(101),
name: "Atlas",
tasks: 12,
archived: false,
},
Project {
id: ProjectId(203),
name: "Boreal",
tasks: 3,
archived: false,
},
Project {
id: ProjectId(307),
name: "Cedar",
tasks: 24,
archived: true,
},
Project {
id: ProjectId(409),
name: "Delta",
tasks: 8,
archived: false,
},
];
Self {
order: projects.iter().map(|project| project.id).collect(),
projects,
selected: None,
active: None,
column: Column::Name,
direction: ui::DataSortDirection::Unsorted,
generation: ViewGeneration(1),
}
}
fn project(&self, id: ProjectId) -> &Project {
self.projects
.iter()
.find(|project| project.id == id)
.expect("known project key")
}
fn sort(&mut self, token: i32) {
let Some(column) = Column::from_token(token) else {
return;
};
// Reject exhaustion instead of reusing an old generation.
let Some(generation) = self.generation.0.checked_add(1) else {
return;
};
self.direction = if column != self.column {
ui::DataSortDirection::Ascending
} else {
match self.direction {
ui::DataSortDirection::Unsorted => ui::DataSortDirection::Ascending,
ui::DataSortDirection::Ascending => ui::DataSortDirection::Descending,
ui::DataSortDirection::Descending => ui::DataSortDirection::Unsorted,
}
};
self.column = column;
let mut order: Vec<_> = self.projects.iter().map(|project| project.id).collect();
if self.direction != ui::DataSortDirection::Unsorted {
order.sort_by(|left, right| {
let left = self.project(*left);
let right = self.project(*right);
let comparison = match column {
Column::Name => left.name.cmp(right.name),
Column::Tasks => left.tasks.cmp(&right.tasks),
}
.then(left.id.0.cmp(&right.id.0));
if self.direction == ui::DataSortDirection::Descending {
comparison.reverse()
} else {
comparison
}
});
}
self.order = order;
self.generation = ViewGeneration(generation);
}
fn activate(&mut self, token: i32, generation: i32) {
let id = ProjectId(token);
if ViewGeneration(generation) == self.generation
&& self.order.contains(&id)
&& !self.project(id).archived
{
self.selected = Some(id);
self.active = Some(id);
}
}
fn navigate(&mut self, step: ui::NavigationStep) {
let enabled: Vec<_> = self
.order
.iter()
.copied()
.filter(|id| !self.project(*id).archived)
.collect();
if enabled.is_empty() {
return;
}
let current = enabled.iter().position(|id| Some(*id) == self.active);
let last = enabled.len() - 1;
let index = match step {
ui::NavigationStep::Next => current.map_or(0, |index| (index + 1).min(last)),
ui::NavigationStep::Previous => current.map_or(last, |index| index.saturating_sub(1)),
ui::NavigationStep::First => 0,
ui::NavigationStep::Last => last,
ui::NavigationStep::PageNext => current.map_or(0, |index| (index + 5).min(last)),
ui::NavigationStep::PagePrevious => {
current.map_or(last, |index| index.saturating_sub(5))
}
};
self.active = Some(enabled[index]);
}
}
struct Projection {
generation: i32,
active_index: i32,
rows: Vec<ui::TableRow>,
columns: Vec<ui::TableColumn>,
selection_label: String,
}
fn project(state: &Catalogue, french: bool) -> Projection {
let generation = state.generation.0;
let active_index = state
.order
.iter()
.position(|id| Some(*id) == state.active)
.map_or(-1, |index| index as i32);
let rows = state
.order
.iter()
.map(|id| {
let project = state.project(*id);
ui::TableRow {
token: id.0,
cells: ModelRc::new(VecModel::from(vec![
project.name.into(),
project.tasks.to_string().into(),
])),
selected: state.selected == Some(*id),
disabled: project.archived,
}
})
.collect();
let columns = [Column::Name, Column::Tasks]
.iter()
.map(|column| ui::TableColumn {
token: if *column == Column::Name { 1 } else { 2 },
label: match (column, french) {
(Column::Name, false) => "Project",
(Column::Name, true) => "Projet",
(Column::Tasks, false) => "Tasks",
(Column::Tasks, true) => "Tâches",
}
.into(),
sortable: true,
sort: if *column == state.column {
state.direction
} else {
ui::DataSortDirection::Unsorted
},
alignment: if *column == Column::Name {
ui::DataAlignment::Leading
} else {
ui::DataAlignment::Trailing
},
})
.collect();
let selection_label = match state.selected {
Some(id) => format!(
"{}: {} · #{}",
if french { "Sélection" } else { "Selected" },
state.project(id).name,
id.0
),
None => {
if french {
"Aucune sélection".into()
} else {
"No selection".into()
}
}
};
Projection {
generation,
active_index,
rows,
columns,
selection_label,
}
}
fn publish(
state: &RefCell<Catalogue>,
app: &ui::TableApp,
rows: &VecModel<ui::TableRow>,
french: bool,
) {
let projection = project(&state.borrow(), french);
// No state borrow is held while model notifications or window updates run.
app.set_generation(projection.generation);
app.set_active_index(projection.active_index);
rows.set_vec(projection.rows);
app.set_columns(ModelRc::new(VecModel::from(projection.columns)));
app.set_selection_label(projection.selection_label.into());
}
pub fn create_app(french: bool) -> Result<ui::TableApp, slint::PlatformError> {
let app = ui::TableApp::new()?;
slint_kit::init_form_validation!(ui, app);
app.set_language(if french {
ui::TableLanguage::French
} else {
ui::TableLanguage::English
});
let state = Rc::new(RefCell::new(Catalogue::new()));
let rows = Rc::new(VecModel::default());
app.set_rows(ModelRc::from(rows.clone()));
publish(&state, &app, &rows, french);
let weak = app.as_weak();
let sort_state = state.clone();
let sort_rows = rows.clone();
app.on_sort_requested(move |token| {
{
sort_state.borrow_mut().sort(token);
}
if let Some(app) = weak.upgrade() {
publish(&sort_state, &app, &sort_rows, french);
}
});
let weak = app.as_weak();
let activate_state = state.clone();
let activate_rows = rows.clone();
app.on_activate(move |token, generation| {
{
activate_state.borrow_mut().activate(token, generation);
}
if let Some(app) = weak.upgrade() {
publish(&activate_state, &app, &activate_rows, french);
}
});
let weak = app.as_weak();
app.on_navigate(move |step| {
{
state.borrow_mut().navigate(step);
}
if let Some(app) = weak.upgrade() {
publish(&state, &app, &rows, french);
}
});
Ok(app)
}
pub fn main() -> Result<(), slint::PlatformError> {
let app = create_app(std::env::args().any(|arg| arg == "--fr"))?;
app.run()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_survives_sort_and_stale_disabled_or_unknown_rows_are_rejected() {
let mut state = Catalogue::new();
state.activate(101, state.generation.0);
let previous = state.generation;
state.sort(2);
assert_eq!(state.order[0], ProjectId(203));
assert_eq!(state.selected, Some(ProjectId(101)));
state.activate(409, previous.0);
state.activate(307, state.generation.0);
state.activate(-1, state.generation.0);
assert_eq!(state.selected, Some(ProjectId(101)));
state.navigate(ui::NavigationStep::Last);
assert_eq!(state.active, Some(ProjectId(101)));
state.navigate(ui::NavigationStep::First);
state.activate(state.active.expect("active project").0, state.generation.0);
assert_eq!(state.selected, Some(ProjectId(203)));
state.generation = ViewGeneration(i32::MAX);
state.sort(1);
assert_eq!(state.generation.0, i32::MAX);
assert_eq!(state.order[0], ProjectId(203));
}
}
Run and adapt
cargo run
Name comparison uses Rust string ordering; it does not implement linguistic collation. Cedar keeps its name in both languages. Internal accessible sort labels follow the kit version in use. This winit/Skia window and its screenshots do not qualify a screen reader or every platform.
See the KitDataTable reference for the full API and the controller guide for other state models. The validated field and selection application complements this example.