Slint Kit Documentation

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
Native application with four projects, two sortable columns and one archived row
Native screenshot of the same example, rendered with Skia.
  1. Click Atlas to select project #101.
  2. Click Tasks: values sort numerically, not as strings. A second click reverses sorting, and a third restores the original order.
  3. Selection stays on #101 after sorting. The token identifies the project, never its visual row position.
  4. 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.