Tree
Tree renders hierarchical data — directories, JSON structures, ASTs,
issue-tracker categories — with the usual expand / collapse semantics and
keyboard-driven traversal. Like List and Table, it is a
StatefulWidget: selection and expansion state live on a TreeState you
own, and render-time mutation keeps the selected node visible inside the
viewport.
It also supports lazy loading: instead of building a complete
Vec<TreeNode> up front, you can attach a loader that materialises
children the first time a node is expanded.
Source: tree.rs:304
(77 KB, one of the larger widgets).
Node shape
Source: tree.rs:102.
pub struct TreeNode {
pub id: TreeNodeId, // stable ID, opaque to the widget
pub label: String, // visible label
pub children: Vec<TreeNode>, // direct children
// … plus style, icon, tooltip, focusable flag, etc.
}IDs are stable across expansions so you can hand-roll selection behaviour (“re-select node 42 after this reload”) without tracking indices.
Building a tree
A static tree is a plain nested Vec:
use ftui_widgets::StatefulWidget;
use ftui_widgets::tree::{Tree, TreeState, TreeNode};
let root = TreeNode::new("root", "Project")
.with_children(vec![
TreeNode::new("src", "src/")
.with_children(vec![
TreeNode::new("src/main.rs", "main.rs"),
TreeNode::new("src/lib.rs", "lib.rs"),
]),
TreeNode::new("cargo", "Cargo.toml"),
]);
let tree = Tree::new(vec![root]);
let mut state = TreeState::default();
StatefulWidget::render(&tree, area, frame, &mut state);Expand / collapse
The canonical keybindings are:
| Key | Action |
|---|---|
j / ↓ | Move selection down |
k / ↑ | Move selection up |
l / → | Expand (or move to first child if already expanded) |
h / ← | Collapse (or move to parent if already collapsed) |
Enter | Activate the selected node |
Space | Toggle expansion |
The widget does not bind keys for you — your update() decides which
gesture maps to which TreeState method. The binding table above is the
convention used by the showcase screens.
TreeState
TreeState tracks:
selected: Option<TreeNodeId>— active node (ID-based, not index)expanded: AHashSet<TreeNodeId>— currently expanded nodesoffset— scroll offset into the flattened visible list- Cached flattened visible ordering for fast rendering
Because selection is by TreeNodeId, refreshing the underlying tree
(reloading a directory listing, for example) preserves selection as long
as the same ID still exists.
Lazy loading
For trees that are expensive to build (file systems, remote APIs), attach a loader:
let tree = Tree::new(roots)
.lazy_loader(|node_id: TreeNodeId| -> Vec<TreeNode> {
// Called the first time `node_id` is expanded.
load_children_for(node_id)
});The widget caches the loader’s result on the tree side: a second expansion of the same node uses the cached children. Invalidate by mutating the node through the state’s mutable API.
The loader runs synchronously during event handling. For slow I/O
(remote directories, SQL queries), return a placeholder node
immediately and trigger an async task from update() that fills in the
real children on completion.
Worked example: directory browser
use ftui_widgets::StatefulWidget;
use ftui_widgets::tree::{Tree, TreeState, TreeNode, TreeNodeId};
use std::path::PathBuf;
pub struct Model {
tree_state: TreeState,
}
fn load_dir(path: &PathBuf) -> Vec<TreeNode> {
std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.map(|e| {
let p = e.path();
let label = p.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| p.display().to_string());
let id = TreeNodeId::from(p.display().to_string());
if p.is_dir() {
// Stub: lazy loader will fill children on first expand.
TreeNode::new(id, label).with_children(vec![])
} else {
TreeNode::new(id, label)
}
})
.collect()
}
// `Model::view` is `&self`; this helper uses `&mut self` so
// `StatefulWidget::render` can borrow `&mut self.tree_state`. Wrap
// `tree_state` in `RefCell<TreeState>` to call directly from `view`.
fn render_tree(&mut self, frame: &mut ftui_render::frame::Frame) {
let area = frame.buffer.bounds();
let roots = load_dir(&PathBuf::from("."));
let tree = Tree::new(roots)
.lazy_loader(|id| {
// Reconstruct path from the ID (opaque string we set earlier).
let path = PathBuf::from(id.as_str());
load_dir(&path)
});
StatefulWidget::render(&tree, area, frame, &mut self.tree_state);
}Composition
Tree plays nicely with the usual container widgets:
- Wrap in
BlockwithBorders::ALLfor a panel frame. - Combine with
Scrollbarfor a visible scroll thumb. - Pair with a
VirtualizedListfor trees with tens of thousands of nodes — convert each visible tree row to a virtualized item.
Performance notes
Render cost is proportional to the number of visible nodes, not the total tree size. A 100K-node tree with only the root expanded renders in O(root’s direct children).
The flattened visible list is cached on TreeState and invalidated when:
- A node is expanded or collapsed.
- Children are loaded lazily.
- Node ordering changes (rare; forces a recompute).
Avoid recomputing the whole tree every frame; build it once, mutate through state.
Pitfalls
- Using array indices as
TreeNodeId. Indices shift when you insert or remove nodes; selection jumps. Use content-based IDs (paths, UUIDs, database keys). - Loader with side effects. The loader runs synchronously during expansion, which is fine for memory but dangerous for I/O. Wrap in a short-circuit check and dispatch real work asynchronously.
- Forgetting to flatten after mutation. Direct mutation of the tree’s
backing
Vec(without going through state) can desync the cached visible list. Always mutate through the state’s API. - Scrolling with
h/l. Those keys are for expand / collapse; scrolling isj/kor the viewport scroll keys.
Where next
When the tree has 100K+ nodes and a flattened view would blow the budget.
Virtualized listA purpose-built variant for file system navigation.
File pickerA syntax-highlighted tree variant specialised for JSON documents.
JSON viewWhen your data is tabular, not hierarchical.
TableHow this piece fits in widgets.
Widgets overview