Tree
A versatile tree component for displaying hierarchical data with expand/collapse functionality, keyboard navigation, and custom item rendering. Perfect for file explorers, navigation menus, or any nested data structure.
#Import
use gpui_kit::component::tree::{tree, TreeState, TreeItem, TreeEntry};// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { TreeNode, Tree } from "tree.slint";use std::collections::HashSet;#Usage
#Basic Tree
// Create tree state
let tree_state = cx.new(|cx| {
TreeState::new(cx).items(vec![
TreeItem::new("src", "src")
.expanded(true)
.child(TreeItem::new("src/lib.rs", "lib.rs"))
.child(TreeItem::new("src/main.rs", "main.rs")),
TreeItem::new("Cargo.toml", "Cargo.toml"),
TreeItem::new("README.md", "README.md"),
])
});
// Render tree
tree(&tree_state, |ix, entry, selected, window, cx| {
ListItem::new(ix)
.child(
h_flex()
.gap_2()
.child(entry.item().label.clone())
)
})Tree {
items <=> root.basic-items;
selected: 1;
toggled(index) => {
// `src` is row 0; `ui` has nothing under it.
root.basic-items[
1
]
.hidden = !root.basic-items[
0
]
.expanded;
root.basic-items[
2
]
.hidden = !root.basic-items[
0
]
.expanded;
}
}// `src` opens on boot and `components` does not: the chevron shows which, and
// the children of a folder are wrapped in `collapsible::reveal`, so opening
// one slides its rows out over 150ms instead of snapping them into place.
// The reveal stays mounted while the folder is closed, which is what lets a
// second click mid-flight turn it around from where it is.
let basic = vec![
folder(
"src",
"src",
app.open.contains("src"),
vec![file("src/lib.rs", "lib.rs"), file("src/main.rs", "main.rs")],
),
folder(
"components",
"components",
app.open.contains("components"),
vec![
file("src/components/tree.rs", "tree.rs"),
file("src/components/calendar.rs", "calendar.rs"),
],
),
file("Cargo.toml", "Cargo.toml"),
];#File Tree with Icons
use gpui_kit::component::{ListItem, IconName, h_flex};
tree(&tree_state, |ix, entry, selected, window, cx| {
let item = entry.item();
let icon = if !entry.is_folder() {
IconName::File
} else if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::Folder
};
ListItem::new(ix)
.selected(selected)
.pl(px(16.) * entry.depth() + px(12.)) // Indent based on depth
.child(
h_flex()
.gap_2()
.child(icon)
.child(item.label.clone())
)
.on_click(cx.listener(move |_, _, _, _| {
// Handle item click
}))
})Tree {
items <=> root.file-items;
selected: 2;
toggled(index) => {
// A row is hidden when any folder above it is closed, so each
// row is written out with its own chain.
root.file-items[
1
]
.hidden = !root.file-items[
0
]
.expanded;
root.file-items[
2
]
.hidden = !root.file-items[
0
]
.expanded || !root.file-items[
1
]
.expanded;
root.file-items[
3
]
.hidden = !root.file-items[
0
]
.expanded || !root.file-items[
1
]
.expanded;
root.file-items[
4
]
.hidden = !root.file-items[
0
]
.expanded || !root.file-items[
1
]
.expanded;
root.file-items[
5
]
.hidden = !root.file-items[
0
]
.expanded;
root.file-items[
6
]
.hidden = !root.file-items[
0
]
.expanded;
}
}// The Slint example's file tree: the folder and file glyphs are the caller's
// elements through `Node::icon`, and the arrow is the component's own Lucide
// chevron unless the caller supplies one.
let files = vec![folder(
"slint-component",
"slint-component",
app.open.contains("slint-component"),
vec![
folder(
"slint-component/ui",
"ui",
app.open.contains("slint-component/ui"),
vec![
file("slint-component/ui/dock.slint", "dock.slint"),
file("slint-component/ui/menu.slint", "menu.slint"),
file("slint-component/ui/theme.slint", "theme.slint"),
],
),
file("slint-component/Cargo.toml", "Cargo.toml"),
file("slint-component/README.md", "README.md"),
],
)];#Dynamic Tree Loading
impl MyView {
fn load_files(&mut self, path: PathBuf, cx: &mut Context<Self>) {
let tree_state = self.tree_state.clone();
cx.spawn(async move |cx| {
let items = build_file_items(&path).await;
tree_state.update(cx, |state, cx| {
state.set_items(items, cx);
})
}).detach();
}
}
fn build_file_items(path: &Path) -> Vec<TreeItem> {
let mut items = Vec::new();
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Unknown")
.to_string();
if path.is_dir() {
let children = build_file_items(&path);
items.push(TreeItem::new(path.to_string_lossy(), name)
.children(children));
} else {
items.push(TreeItem::new(path.to_string_lossy(), name));
}
}
}
items
}SlintNo Slint version of this example yet.
#Tree with Selection Handling
struct MyTreeView {
tree_state: Entity<TreeState>,
selected_item: Option<TreeItem>,
}
impl MyTreeView {
fn handle_selection(&mut self, item: TreeItem, cx: &mut Context<Self>) {
self.selected_item = Some(item.clone());
println!("Selected: {} ({})", item.label, item.id);
cx.notify();
}
}
// In render method
tree(&self.tree_state, {
let view = cx.entity();
move |ix, entry, selected, window, cx| {
view.update(cx, |this, cx| {
ListItem::new(ix)
.selected(selected)
.child(entry.item().label.clone())
.on_click(cx.listener({
let item = entry.item().clone();
move |this, _, _, cx| {
this.handle_selection(item.clone(), cx);
}
}))
})
}
})Rectangle {
horizontal-stretch: 1;
height: 190px;
border-width: 1px;
border-color: Theme.border;
border-radius: Theme.radius-md;
clip: true;
HorizontalLayout {
spacing: 0;
Rectangle {
width: 220px;
background: Theme.sidebar;
Tree {
items <=> root.settings-items;
selected <=> root.picked;
toggled(index) => {
root.settings-items[
1
]
.hidden = !root.settings-items[
0
]
.expanded;
root.settings-items[
2
]
.hidden = !root.settings-items[
0
]
.expanded;
root.settings-items[
4
]
.hidden = !root.settings-items[
3
]
.expanded;
root.settings-items[
5
]
.hidden = !root.settings-items[
3
]
.expanded;
}
}
}
Rectangle {
width: 1px;
background: Theme.border;
}
VerticalLayout {
horizontal-stretch: 1;
padding: 16px;
spacing: 6px;
alignment: start;
Text {
text: root.picked >= 0 && root.picked < root.settings-items.length
? root.settings-items[
root.picked
]
.label
: "Nothing selected";
color: Theme.foreground;
font-size: Theme.font-size-sm;
font-weight: 600;
}
Text {
text: root.picked >= 0 && root.picked < root.settings-items.length
? "Section \{root.picked + 1} of \{root.settings-items.length}"
: "Pick a row on the left.";
color: Theme.muted-foreground;
font-size: Theme.font-size-xs;
}
}
}
}// The tree beside the thing it selects, which is how a settings window or a
// file browser puts one to work. The component reports the id; the demo maps
// it back to the row it names.
let settings = vec![
folder(
"workspace",
"Workspace",
app.open.contains("workspace"),
vec![file("general", "General"), file("appearance", "Appearance")],
),
folder(
"editor",
"Editor",
app.open.contains("editor"),
vec![file("fonts", "Fonts"), file("keymap", "Keymap")],
),
];
let picked = app
.picked
.and_then(|id| SETTINGS.iter().position(|(row, _)| *row == id));
let title = picked
.map(|ix| SETTINGS[ix].1)
.unwrap_or("Nothing selected");
let subtitle = match picked {
Some(ix) => format!("Section {} of {}", ix + 1, SETTINGS.len()),
None => "Pick a row on the left.".to_string(),
};
let detail = container(
column![
line(title, theme::FONT_SIZE_SM, ink),
// Owned, because the section count is formatted: `line` borrows and the
// view returns this element past the call.
text(subtitle)
.size(theme::FONT_SIZE_XS)
.style(|theme: &Theme| text::Style {
color: Some(Tokens::of(theme).muted_foreground),
}),
]
.spacing(6.0),
)
.width(Length::Fill)
.height(Length::Fill)
.padding(16.0);
let sidebar = container(tree(app, settings, app.picked, Message::SelectSetting))
.width(Length::Fixed(220.0))
.height(Length::Fill)
.padding(Padding {
top: 4.0,
right: 4.0,
bottom: 4.0,
left: 4.0,
})
.style(|theme: &Theme| container::Style {
background: Some(Tokens::of(theme).sidebar.into()),
..container::Style::default()
});
let divider = container(Space::new())
.width(Length::Fixed(1.0))
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Tokens::of(theme).border.into()),
..container::Style::default()
});
let selection = container(
row![sidebar, divider, detail]
.spacing(0.0)
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fixed(190.0))
.style(|theme: &Theme| container::Style {
border: Border {
color: Tokens::of(theme).border,
width: 1.0,
radius: theme::RADIUS_MD.into(),
},
..container::Style::default()
});#Disabled Items
TreeItem::new("protected", "Protected Folder")
.disabled(true)
.child(TreeItem::new("secret.txt", "secret.txt"))SlintNo Slint version of this example yet.
#Programmatic Tree Control
// Get current selection
if let Some(entry) = tree_state.read(cx).selected_entry() {
println!("Current selection: {}", entry.item().label);
}
// Set selection programmatically (by selected_index)
tree_state.update(cx, |state, cx| {
state.set_selected_index(Some(2), cx); // Select third item
});
// Set selection programmatically (by tree item)
tree_state.update(cx, |state, cx| {
state.set_selected_item(Some(item), cx); // Select third item
});
// Scroll to specific item
tree_state.update(cx, |state, _| {
state.scroll_to_item(5, gpui_kit::ScrollStrategy::Center);
});
// Clear selection (by selected_index)
tree_state.update(cx, |state, cx| {
state.set_selected_index(None, cx);
});
// Clear selection (by tree item)
tree_state.update(cx, |state, cx| {
state.set_selected_item(None, cx);
});SlintNo Slint version of this example yet.
#Lazy Loading Tree
struct LazyTreeView {
tree_state: Entity<TreeState>,
loaded_paths: HashSet<String>,
}
impl LazyTreeView {
fn load_children(&mut self, item_id: &str, cx: &mut Context<Self>) {
if self.loaded_paths.contains(item_id) {
return;
}
let path = PathBuf::from(item_id);
if path.is_dir() {
let tree_state = self.tree_state.clone();
let item_id = item_id.to_string();
cx.spawn(async move |cx| {
let children = load_directory_children(&path).await;
tree_state.update(cx, |state, cx| {
// Update specific item with loaded children
state.update_item_children(&item_id, children, cx);
})
}).detach();
self.loaded_paths.insert(item_id.to_string());
}
}
}SlintNo Slint version of this example yet.
#Search and Filter
struct SearchableTree {
tree_state: Entity<TreeState>,
original_items: Vec<TreeItem>,
search_query: String,
}
impl SearchableTree {
fn filter_tree(&mut self, query: &str, cx: &mut Context<Self>) {
self.search_query = query.to_string();
let filtered_items = if query.is_empty() {
self.original_items.clone()
} else {
filter_tree_items(&self.original_items, query)
};
self.tree_state.update(cx, |state, cx| {
state.set_items(filtered_items, cx);
});
}
}
fn filter_tree_items(items: &[TreeItem], query: &str) -> Vec<TreeItem> {
items.iter()
.filter_map(|item| {
if item.label.to_lowercase().contains(&query.to_lowercase()) {
Some(item.clone().expanded(true)) // Auto-expand matches
} else {
// Check if any children match
let filtered_children = filter_tree_items(&item.children, query);
if !filtered_children.is_empty() {
Some(item.clone()
.children(filtered_children)
.expanded(true))
} else {
None
}
}
})
.collect()
}SlintNo Slint version of this example yet.
#Multi-Select Tree
struct MultiSelectTree {
tree_state: Entity<TreeState>,
selected_items: HashSet<String>,
}
impl MultiSelectTree {
fn toggle_selection(&mut self, item_id: &str, cx: &mut Context<Self>) {
if self.selected_items.contains(item_id) {
self.selected_items.remove(item_id);
} else {
self.selected_items.insert(item_id.to_string());
}
cx.notify();
}
fn is_selected(&self, item_id: &str) -> bool {
self.selected_items.contains(item_id)
}
}
// In render method
tree(&self.tree_state, {
let view = cx.entity();
move |ix, entry, _selected, window, cx| {
view.update(cx, |this, cx| {
let item = entry.item();
let is_multi_selected = this.is_selected(&item.id);
ListItem::new(ix)
.selected(is_multi_selected)
.child(
h_flex()
.gap_2()
.child(checkbox().checked(is_multi_selected))
.child(item.label.clone())
)
.on_click(cx.listener({
let item_id = item.id.clone();
move |this, _, _, cx| {
this.toggle_selection(&item_id, cx);
}
}))
})
}
})SlintNo Slint version of this example yet.
#Keyboard Navigation
The Tree component supports comprehensive keyboard navigation:
| Key | Action |
|---|---|
↑ | Select previous item |
↓ | Select next item |
← | Collapse current folder or move to parent |
→ | Expand current folder |
Enter | Toggle expand/collapse for folders |
Space | Custom action (configurable) |
// Custom keyboard handling
tree(&tree_state)
.key_context("MyTree")
.on_action(cx.listener(|this, action: &MyCustomAction, _, cx| {
// Handle custom actions
}))SlintNo Slint version of this example yet.
#API Reference
#TreeState
| Method | Description |
|---|---|
new(cx) | Create a new tree state |
items(items) | Set initial tree items |
set_items(items, cx) | Update tree items and notify |
selected_index() | Get currently selected index |
set_selected_index(ix, cx) | Set selected index |
set_selected_item(item, cx) | Set selected by tree item |
selected_item(item, cx) | Get currently selected tree item |
selected_entry() | Get currently selected entry |
scroll_to_item(ix, strategy) | Scroll to specific item |
#TreeItem
| Method | Description |
|---|---|
new(id, label) | Create new tree item with ID and label |
child(item) | Add single child item |
children(items) | Add multiple child items |
expanded(bool) | Set expanded state |
disabled(bool) | Set disabled state |
is_folder() | Check if item has children |
is_expanded() | Check if item is expanded |
is_disabled() | Check if item is disabled |
#TreeEntry
| Method | Description |
|---|---|
item() | Get the source TreeItem |
depth() | Get item depth in tree |
is_folder() | Check if entry has children |
is_expanded() | Check if entry is expanded |
is_disabled() | Check if entry is disabled |
#tree() Function
| Parameter | Description |
|---|---|
state | Entity<TreeState> for managing tree |
render_item | Closure for rendering each item |
#Render Item Closure
Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItemSlintNo Slint version of this example yet.
usize: Item index in flattened tree&TreeEntry: Tree entry with item and metadatabool: Whether item is currently selected&mut Window: Current window context&mut App: Application context- Returns:
ListItemfor rendering
#TreeNode
| Name | Kind | Type | Default | Description |
|---|---|---|---|---|
label | field | string | ||
depth | field | int | ||
folder | field | bool | ||
expanded | field | bool |
#Tree
A file tree. Mirrors GPUI Component's `Tree` with a flattened model. The disclosure arrow owns a hit area of its own, over the row's, so clicking it opens the folder instead of only selecting the row.
| Name | Kind | Type | Default | Description |
|---|---|---|---|---|
items | in-out | [TreeNode] | ||
selected | in-out | int | -1 | |
changed | callback | (index: int) | ||
toggled | callback | (index: int) | Fired after the arrow flips a folder, so the owner can show or hide the rows underneath it. |