Tableau triable avec identité stable
Cette application Rust et Slint complète raccorde le tri des colonnes, la navigation clavier et la sélection d’un KitDataTable. Rust conserve les projets et leurs identifiants ; le tableau virtualise leur présentation. L’exemple utilise Slint Kit 0.3.0, Rust 1.92+ et Slint 1.18.1.
Choisissez Atlas, puis triez la colonne Tâches : Atlas reste sélectionné même s’il change de ligne. Cedar est archivé et ne peut pas être sélectionné. Un événement provenant d’une ancienne génération est rejeté.
Essayer l’application
Créez un projet neuf, puis remplacez ses fichiers par les quatre fichiers complets ci-dessous. Les paquets viennent de crates.io ; aucun checkout du kit n’est nécessaire. Les prérequis natifs sont les mêmes que pour le guide de démarrage.
cargo new slint-kit-table-example
cd slint-kit-table-example

- Cliquez sur Atlas pour sélectionner le projet
#101. - Cliquez sur Tâches : les valeurs sont triées numériquement, pas comme du texte. Un deuxième clic inverse le tri, un troisième rétablit l’ordre d’origine.
- La sélection reste
#101après le tri. Le jeton représente le projet, jamais sa position visuelle. - Donnez le focus au tableau, puis utilisez les flèches, Début, Fin et Entrée. Cedar est ignoré. Les touches de page déplacent le focus de cinq projets sélectionnables au maximum.
Pourquoi la génération change
ProjectId est une clé métier stable. ViewGeneration identifie l’ordre projeté. Chaque tri incrémente cette génération ; activate(token, generation) vérifie la génération, l’existence du projet et son état avant d’accepter la sélection. La ligne active suit elle aussi une clé. active-index n’est qu’une projection destinée au focus et au défilement.
Le modèle VecModel est conservé entre les publications. Le tableau a un viewport borné et utilise la vue virtualisée du kit. Le contrôleur libère ses emprunts d’état avant de notifier le modèle ou de mettre à jour la fenêtre ; ses callbacks ne conservent qu’une référence faible vers celle-ci. Les quatre projets rendent le mécanisme lisible ; pour des données volumineuses, adaptez aussi le filtrage et le tri côté hôte.
Cargo.toml
Télécharger cet exemple — 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
Télécharger cet exemple — 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
Télécharger cet exemple — 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
Télécharger cet exemple — 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));
}
}
Exécuter et adapter
cargo run -- --fr
La comparaison de noms utilise l’ordre des chaînes Rust ; elle n’implémente pas une collation linguistique. Cedar garde son nom dans les deux langues. Les libellés accessibles internes de tri du composant suivent la version du kit utilisée. Cette fenêtre winit/Skia et ses captures ne qualifient pas un lecteur d’écran ou toutes les plateformes.
Consultez la référence KitDataTable pour l’API complète et le guide des contrôleurs pour les autres modèles d’état. L’application de champ validé et sélection complète cet exemple.