use std::collections::HashSet;
use gpui::prelude::*;
use gpui::{
div, px, ClickEvent, Context, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
MouseButton, SharedString, Window,
};
use crate::icon::{Icon, IconName};
use crate::reactive::Signal;
use crate::theme::{theme, Size};
#[derive(Debug, Clone)]
pub struct TreeNode {
pub id: SharedString,
pub label: SharedString,
pub icon: Option<IconName>,
pub children: Vec<TreeNode>,
}
impl TreeNode {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
TreeNode {
id: id.into(),
label: label.into(),
icon: None,
children: Vec::new(),
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn child(mut self, child: TreeNode) -> Self {
self.children.push(child);
self
}
pub fn children(mut self, children: impl IntoIterator<Item = TreeNode>) -> Self {
self.children.extend(children);
self
}
pub fn is_leaf(&self) -> bool {
self.children.is_empty()
}
}
#[derive(Debug, Clone)]
pub enum TreeViewEvent {
Selected(SharedString),
Toggled(SharedString, bool),
Activated(SharedString),
}
#[derive(Debug, Clone, PartialEq)]
struct VisibleRow {
id: SharedString,
label: SharedString,
icon: Option<IconName>,
depth: usize,
is_branch: bool,
expanded: bool,
}
fn flatten_visible(
nodes: &[TreeNode],
expanded: &HashSet<SharedString>,
depth: usize,
out: &mut Vec<VisibleRow>,
) {
for node in nodes {
let is_branch = !node.children.is_empty();
let is_expanded = is_branch && expanded.contains(&node.id);
out.push(VisibleRow {
id: node.id.clone(),
label: node.label.clone(),
icon: node.icon,
depth,
is_branch,
expanded: is_expanded,
});
if is_expanded {
flatten_visible(&node.children, expanded, depth + 1, out);
}
}
}
fn visible(nodes: &[TreeNode], expanded: &HashSet<SharedString>) -> Vec<VisibleRow> {
let mut out = Vec::new();
flatten_visible(nodes, expanded, 0, &mut out);
out
}
fn collect_branch_ids(nodes: &[TreeNode], out: &mut HashSet<SharedString>) {
for node in nodes {
if !node.children.is_empty() {
out.insert(node.id.clone());
collect_branch_ids(&node.children, out);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeyMove {
To(usize),
Set(usize, bool),
None,
}
fn step_down(len: usize, current: Option<usize>) -> Option<usize> {
match (len, current) {
(0, _) => None,
(_, None) => Some(0),
(len, Some(i)) => Some((i + 1).min(len - 1)),
}
}
fn step_up(len: usize, current: Option<usize>) -> Option<usize> {
match (len, current) {
(0, _) => None,
(len, None) => Some(len - 1),
(_, Some(i)) => Some(i.saturating_sub(1)),
}
}
fn step_right(rows: &[VisibleRow], current: usize) -> KeyMove {
let Some(row) = rows.get(current) else {
return KeyMove::None;
};
if !row.is_branch {
return KeyMove::None;
}
if !row.expanded {
return KeyMove::Set(current, true);
}
match rows.get(current + 1) {
Some(next) if next.depth == row.depth + 1 => KeyMove::To(current + 1),
_ => KeyMove::None,
}
}
fn step_left(rows: &[VisibleRow], current: usize) -> KeyMove {
let Some(row) = rows.get(current) else {
return KeyMove::None;
};
if row.is_branch && row.expanded {
return KeyMove::Set(current, false);
}
if row.depth == 0 {
return KeyMove::None;
}
(0..current)
.rev()
.find(|&i| rows[i].depth + 1 == row.depth)
.map(KeyMove::To)
.unwrap_or(KeyMove::None)
}
pub struct TreeView {
nodes: Vec<TreeNode>,
expanded: HashSet<SharedString>,
selected: Option<SharedString>,
expand_all: bool,
focus: FocusHandle,
}
impl EventEmitter<TreeViewEvent> for TreeView {}
impl TreeView {
pub fn new(cx: &mut Context<Self>) -> Self {
TreeView {
nodes: Vec::new(),
expanded: HashSet::new(),
selected: None,
expand_all: false,
focus: cx.focus_handle(),
}
}
pub fn nodes(mut self, nodes: Vec<TreeNode>) -> Self {
self.nodes = nodes;
if self.expand_all {
collect_branch_ids(&self.nodes, &mut self.expanded);
}
self
}
pub fn bind_nodes(mut self, signal: &Signal<Vec<TreeNode>>, cx: &mut Context<Self>) -> Self {
self.nodes = signal.get(cx);
if self.expand_all {
collect_branch_ids(&self.nodes, &mut self.expanded);
}
cx.observe(signal.entity(), |this, observed, cx| {
this.nodes = observed.read(cx).clone();
if this.expand_all {
collect_branch_ids(&this.nodes, &mut this.expanded);
}
cx.notify();
})
.detach();
self
}
pub fn expand(mut self, id: impl Into<SharedString>) -> Self {
self.expanded.insert(id.into());
self
}
pub fn collapse(mut self, id: impl Into<SharedString>) -> Self {
self.expanded.remove(&id.into());
self
}
pub fn default_expanded(mut self, expanded: bool) -> Self {
self.expand_all = expanded;
if expanded {
collect_branch_ids(&self.nodes, &mut self.expanded);
}
self
}
pub fn expanded_ids(&self) -> Vec<SharedString> {
let mut ids: Vec<SharedString> = self.expanded.iter().cloned().collect();
ids.sort();
ids
}
pub fn selected_id(&self) -> Option<SharedString> {
self.selected.clone()
}
fn select(&mut self, id: SharedString, cx: &mut Context<Self>) {
if self.selected.as_ref() == Some(&id) {
return;
}
self.selected = Some(id.clone());
cx.emit(TreeViewEvent::Selected(id));
cx.notify();
}
fn toggle(&mut self, id: SharedString, cx: &mut Context<Self>) {
let open = !self.expanded.contains(&id);
self.set_expanded(id, open, cx);
}
fn set_expanded(&mut self, id: SharedString, open: bool, cx: &mut Context<Self>) {
let changed = if open {
self.expanded.insert(id.clone())
} else {
self.expanded.remove(&id)
};
if changed {
cx.emit(TreeViewEvent::Toggled(id, open));
cx.notify();
}
}
fn apply(&mut self, mv: KeyMove, rows: &[VisibleRow], cx: &mut Context<Self>) {
match mv {
KeyMove::To(i) => self.select(rows[i].id.clone(), cx),
KeyMove::Set(i, open) => self.set_expanded(rows[i].id.clone(), open, cx),
KeyMove::None => {}
}
}
fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
let rows = visible(&self.nodes, &self.expanded);
if rows.is_empty() {
return;
}
let current = self
.selected
.as_ref()
.and_then(|id| rows.iter().position(|row| &row.id == id));
let handled = match event.keystroke.key.as_str() {
"down" => {
if let Some(i) = step_down(rows.len(), current) {
self.select(rows[i].id.clone(), cx);
}
true
}
"up" => {
if let Some(i) = step_up(rows.len(), current) {
self.select(rows[i].id.clone(), cx);
}
true
}
"right" => match current {
Some(i) => {
self.apply(step_right(&rows, i), &rows, cx);
true
}
None => false,
},
"left" => match current {
Some(i) => {
self.apply(step_left(&rows, i), &rows, cx);
true
}
None => false,
},
"enter" => match self.selected.clone() {
Some(id) => {
cx.emit(TreeViewEvent::Activated(id));
true
}
None => false,
},
_ => false,
};
if handled {
cx.stop_propagation();
}
}
}
impl Render for TreeView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let t = theme(cx);
let text = t.text().hsla();
let dimmed = t.dimmed().hsla();
let accent = t.primary().hsla();
let surface_hover = t.surface_hover().hsla();
let selected_bg = t.primary().alpha(0.12);
let indent = t.spacing(Size::Md);
let radius = t.radius(Size::Sm);
let font = t.font_size(Size::Sm);
let rows = visible(&self.nodes, &self.expanded);
let selected = self.selected.clone();
let mut root = div()
.id("guise-treeview")
.track_focus(&self.focus)
.on_key_down(cx.listener(Self::on_key))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _ev, window, cx| {
window.focus(&this.focus);
cx.notify();
}),
)
.flex()
.flex_col()
.gap(px(2.0));
for (i, row) in rows.into_iter().enumerate() {
let is_selected = selected.as_ref() == Some(&row.id);
let is_branch = row.is_branch;
let id = row.id.clone();
let hover_bg = if is_selected {
selected_bg
} else {
surface_hover
};
let mut chevron = div()
.w(px(16.0))
.flex()
.items_center()
.justify_center()
.text_color(dimmed);
if is_branch {
chevron = chevron.child(SharedString::new_static(if row.expanded {
IconName::ChevronDown.glyph()
} else {
IconName::ChevronRight.glyph()
}));
}
let fallback = if is_branch {
IconName::Menu
} else {
IconName::Dot
};
let glyph = row.icon.unwrap_or(fallback);
let icon = div()
.text_color(if is_selected { accent } else { dimmed })
.child(Icon::new(glyph).size(Size::Xs));
let mut el = div()
.id(("guise-tree-row", i))
.flex()
.items_center()
.gap(px(6.0))
.pl(px(6.0 + indent * row.depth as f32))
.pr(px(8.0))
.py(px(4.0))
.rounded(px(radius))
.text_size(px(font))
.text_color(text)
.hover(move |s| s.bg(hover_bg))
.child(chevron)
.child(icon)
.child(row.label.clone())
.on_click(cx.listener(move |this, ev: &ClickEvent, _window, cx| {
this.select(id.clone(), cx);
if ev.click_count() > 1 {
cx.emit(TreeViewEvent::Activated(id.clone()));
} else if is_branch {
this.toggle(id.clone(), cx);
}
}));
if is_selected {
el = el.bg(selected_bg);
}
root = root.child(el);
}
root
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<TreeNode> {
vec![
TreeNode::new("src", "src")
.child(TreeNode::new("main", "main.rs"))
.child(TreeNode::new("data", "data").child(TreeNode::new("tree", "tree.rs"))),
TreeNode::new("readme", "README.md"),
]
}
fn expanded(ids: &[&'static str]) -> HashSet<SharedString> {
ids.iter().map(|id| SharedString::from(*id)).collect()
}
fn ids(rows: &[VisibleRow]) -> Vec<&str> {
rows.iter().map(|row| row.id.as_ref()).collect()
}
#[test]
fn collapsed_tree_shows_only_roots() {
let rows = visible(&sample(), &expanded(&[]));
assert_eq!(ids(&rows), ["src", "readme"]);
assert!(rows[0].is_branch && !rows[0].expanded);
assert!(!rows[1].is_branch);
}
#[test]
fn expanded_branches_flatten_depth_first() {
let rows = visible(&sample(), &expanded(&["src", "data"]));
assert_eq!(ids(&rows), ["src", "main", "data", "tree", "readme"]);
let depths: Vec<usize> = rows.iter().map(|row| row.depth).collect();
assert_eq!(depths, [0, 1, 1, 2, 0]);
assert!(rows[2].expanded);
}
#[test]
fn collapsed_parent_hides_expanded_descendants() {
let rows = visible(&sample(), &expanded(&["data"]));
assert_eq!(ids(&rows), ["src", "readme"]);
}
#[test]
fn up_and_down_clamp_at_the_edges() {
assert_eq!(step_down(3, None), Some(0));
assert_eq!(step_down(3, Some(1)), Some(2));
assert_eq!(step_down(3, Some(2)), Some(2));
assert_eq!(step_up(3, None), Some(2));
assert_eq!(step_up(3, Some(1)), Some(0));
assert_eq!(step_up(3, Some(0)), Some(0));
assert_eq!(step_down(0, None), None);
assert_eq!(step_up(0, Some(1)), None);
}
#[test]
fn right_expands_then_steps_into_the_branch() {
let closed = visible(&sample(), &expanded(&[]));
assert_eq!(step_right(&closed, 0), KeyMove::Set(0, true));
let open = visible(&sample(), &expanded(&["src"]));
assert_eq!(step_right(&open, 0), KeyMove::To(1));
assert_eq!(step_right(&open, 1), KeyMove::None);
}
#[test]
fn left_collapses_then_walks_to_the_parent() {
let rows = visible(&sample(), &expanded(&["src", "data"]));
assert_eq!(step_left(&rows, 0), KeyMove::Set(0, false));
assert_eq!(step_left(&rows, 1), KeyMove::To(0));
assert_eq!(step_left(&rows, 3), KeyMove::To(2));
assert_eq!(step_left(&rows, 4), KeyMove::None);
}
#[test]
fn branch_ids_cover_nested_branches_only() {
let mut out = HashSet::new();
collect_branch_ids(&sample(), &mut out);
assert_eq!(out, expanded(&["src", "data"]));
}
}