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.

Install

#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:

KeyAction
↑Select previous item
↓Select next item
←Collapse current folder or move to parent
→Expand current folder
EnterToggle expand/collapse for folders
SpaceCustom 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

MethodDescription
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

MethodDescription
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

MethodDescription
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

ParameterDescription
stateEntity<TreeState> for managing tree
render_itemClosure for rendering each item

#Render Item Closure

Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem
SlintNo Slint version of this example yet.
  • usize: Item index in flattened tree
  • &TreeEntry: Tree entry with item and metadata
  • bool: Whether item is currently selected
  • &mut Window: Current window context
  • &mut App: Application context
  • Returns: ListItem for rendering

#TreeNode

NameKindTypeDefaultDescription
labelfieldstring
depthfieldint
folderfieldbool
expandedfieldbool
hiddenfieldboolWhether the row is painted at all. A flat model cannot work out for itself whether an ancestor is collapsed -- Slint has no loops in functions and no recursion, so the chain cannot be walked -- and the owner is the one that knows the shape of the tree.

#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.

NameKindTypeDefaultDescription
itemsin-out[TreeNode]
selectedin-outint-1
changedcallback(index: int)
toggledcallback(index: int)Fired after the arrow flips a folder, so the owner can show or hide the rows underneath it.