Skip to Content

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:

KeyAction
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)
EnterActivate the selected node
SpaceToggle 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 nodes
  • offset — 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 Block with Borders::ALL for a panel frame.
  • Combine with Scrollbar for a visible scroll thumb.
  • Pair with a VirtualizedList for 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 is j / k or the viewport scroll keys.

Where next