Skip to main content

guise/data/
tree.rs

1//! `TreeView` — a hierarchical list with expandable branches (gpui entity).
2//!
3//! Nodes are plain [`TreeNode`] values; the view owns expansion state and a
4//! single selection, and emits [`TreeViewEvent`] on select / toggle / activate.
5//! Branches show a rotating chevron and rows indent per depth; arrow keys walk
6//! the visible rows (right expands or steps into a branch, left collapses or
7//! steps to the parent) and Enter activates the selection.
8//!
9//! ```ignore
10//! let tree = cx.new(|cx| {
11//!     TreeView::new(cx)
12//!         .nodes(vec![
13//!             TreeNode::new("src", "src")
14//!                 .child(TreeNode::new("main", "main.rs"))
15//!                 .child(TreeNode::new("lib", "lib.rs")),
16//!             TreeNode::new("readme", "README.md"),
17//!         ])
18//!         .expand("src")
19//! });
20//! cx.subscribe(&tree, |_this, _tree, event: &TreeViewEvent, _cx| match event {
21//!     TreeViewEvent::Selected(id) => println!("selected {id}"),
22//!     TreeViewEvent::Toggled(id, open) => println!("{id} expanded: {open}"),
23//!     TreeViewEvent::Activated(id) => println!("activated {id}"),
24//! })
25//! .detach();
26//! ```
27
28use std::collections::HashSet;
29
30use gpui::prelude::*;
31use gpui::{
32    div, px, ClickEvent, Context, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
33    MouseButton, SharedString, Window,
34};
35
36use crate::icon::{Icon, IconName};
37use crate::reactive::Signal;
38use crate::theme::{theme, Size};
39
40/// One node in a [`TreeView`]. A node with children is a branch (chevron +
41/// folder icon); a node without children is a leaf (file icon).
42#[derive(Debug, Clone)]
43pub struct TreeNode {
44    /// Stable identifier — carried by every [`TreeViewEvent`].
45    pub id: SharedString,
46    /// The text shown on the row.
47    pub label: SharedString,
48    /// Optional glyph shown before the label. Defaults to a folder/file glyph
49    /// picked by branch/leaf.
50    pub icon: Option<IconName>,
51    /// Child nodes; empty means leaf.
52    pub children: Vec<TreeNode>,
53}
54
55impl TreeNode {
56    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
57        TreeNode {
58            id: id.into(),
59            label: label.into(),
60            icon: None,
61            children: Vec::new(),
62        }
63    }
64
65    /// Override the row glyph (defaults to folder/file by branch/leaf).
66    pub fn icon(mut self, icon: IconName) -> Self {
67        self.icon = Some(icon);
68        self
69    }
70
71    /// Append one child node.
72    pub fn child(mut self, child: TreeNode) -> Self {
73        self.children.push(child);
74        self
75    }
76
77    /// Append several child nodes.
78    pub fn children(mut self, children: impl IntoIterator<Item = TreeNode>) -> Self {
79        self.children.extend(children);
80        self
81    }
82
83    /// A node with no children.
84    pub fn is_leaf(&self) -> bool {
85        self.children.is_empty()
86    }
87}
88
89/// Emitted by [`TreeView`]. All variants carry the node id.
90#[derive(Debug, Clone)]
91pub enum TreeViewEvent {
92    /// The selection moved to this node.
93    Selected(SharedString),
94    /// A branch was expanded (`true`) or collapsed (`false`).
95    Toggled(SharedString, bool),
96    /// Enter or double-click on a node.
97    Activated(SharedString),
98}
99
100/// One visible (not hidden by a collapsed ancestor) row, in paint order.
101#[derive(Debug, Clone, PartialEq)]
102struct VisibleRow {
103    id: SharedString,
104    label: SharedString,
105    icon: Option<IconName>,
106    depth: usize,
107    is_branch: bool,
108    expanded: bool,
109}
110
111/// Depth-first flatten of the nodes whose ancestors are all expanded.
112fn flatten_visible(
113    nodes: &[TreeNode],
114    expanded: &HashSet<SharedString>,
115    depth: usize,
116    out: &mut Vec<VisibleRow>,
117) {
118    for node in nodes {
119        let is_branch = !node.children.is_empty();
120        let is_expanded = is_branch && expanded.contains(&node.id);
121        out.push(VisibleRow {
122            id: node.id.clone(),
123            label: node.label.clone(),
124            icon: node.icon,
125            depth,
126            is_branch,
127            expanded: is_expanded,
128        });
129        if is_expanded {
130            flatten_visible(&node.children, expanded, depth + 1, out);
131        }
132    }
133}
134
135/// The visible rows for a node list + expanded set.
136fn visible(nodes: &[TreeNode], expanded: &HashSet<SharedString>) -> Vec<VisibleRow> {
137    let mut out = Vec::new();
138    flatten_visible(nodes, expanded, 0, &mut out);
139    out
140}
141
142/// Every branch id in the tree (for `default_expanded`).
143fn collect_branch_ids(nodes: &[TreeNode], out: &mut HashSet<SharedString>) {
144    for node in nodes {
145        if !node.children.is_empty() {
146            out.insert(node.id.clone());
147            collect_branch_ids(&node.children, out);
148        }
149    }
150}
151
152/// What a horizontal arrow key does, in visible-row terms.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154enum KeyMove {
155    /// Move the selection to this visible index.
156    To(usize),
157    /// Expand (`true`) or collapse (`false`) the branch at this index.
158    Set(usize, bool),
159    /// Nothing to do.
160    None,
161}
162
163/// Down-arrow target: first row when nothing is selected, else clamp below.
164fn step_down(len: usize, current: Option<usize>) -> Option<usize> {
165    match (len, current) {
166        (0, _) => None,
167        (_, None) => Some(0),
168        (len, Some(i)) => Some((i + 1).min(len - 1)),
169    }
170}
171
172/// Up-arrow target: last row when nothing is selected, else clamp above.
173fn step_up(len: usize, current: Option<usize>) -> Option<usize> {
174    match (len, current) {
175        (0, _) => None,
176        (len, None) => Some(len - 1),
177        (_, Some(i)) => Some(i.saturating_sub(1)),
178    }
179}
180
181/// Right arrow: expand a collapsed branch, step into an expanded one.
182fn step_right(rows: &[VisibleRow], current: usize) -> KeyMove {
183    let Some(row) = rows.get(current) else {
184        return KeyMove::None;
185    };
186    if !row.is_branch {
187        return KeyMove::None;
188    }
189    if !row.expanded {
190        return KeyMove::Set(current, true);
191    }
192    match rows.get(current + 1) {
193        Some(next) if next.depth == row.depth + 1 => KeyMove::To(current + 1),
194        _ => KeyMove::None,
195    }
196}
197
198/// Left arrow: collapse an expanded branch, else step to the parent.
199fn step_left(rows: &[VisibleRow], current: usize) -> KeyMove {
200    let Some(row) = rows.get(current) else {
201        return KeyMove::None;
202    };
203    if row.is_branch && row.expanded {
204        return KeyMove::Set(current, false);
205    }
206    if row.depth == 0 {
207        return KeyMove::None;
208    }
209    // The parent is the nearest preceding row one level up.
210    (0..current)
211        .rev()
212        .find(|&i| rows[i].depth + 1 == row.depth)
213        .map(KeyMove::To)
214        .unwrap_or(KeyMove::None)
215}
216
217/// A hierarchical list. Create with `cx.new(|cx| TreeView::new(cx).nodes(...))`.
218pub struct TreeView {
219    nodes: Vec<TreeNode>,
220    expanded: HashSet<SharedString>,
221    selected: Option<SharedString>,
222    expand_all: bool,
223    focus: FocusHandle,
224}
225
226impl EventEmitter<TreeViewEvent> for TreeView {}
227
228impl TreeView {
229    pub fn new(cx: &mut Context<Self>) -> Self {
230        TreeView {
231            nodes: Vec::new(),
232            expanded: HashSet::new(),
233            selected: None,
234            expand_all: false,
235            focus: cx.focus_handle(),
236        }
237    }
238
239    /// Set the tree data.
240    pub fn nodes(mut self, nodes: Vec<TreeNode>) -> Self {
241        self.nodes = nodes;
242        if self.expand_all {
243            collect_branch_ids(&self.nodes, &mut self.expanded);
244        }
245        self
246    }
247
248    /// Drive the tree data from a `Signal<Vec<TreeNode>>`: the view adopts the
249    /// signal's nodes now and re-reads them on every signal change. Expansion
250    /// and selection survive data updates (they are keyed by node id).
251    pub fn bind_nodes(mut self, signal: &Signal<Vec<TreeNode>>, cx: &mut Context<Self>) -> Self {
252        self.nodes = signal.get(cx);
253        if self.expand_all {
254            collect_branch_ids(&self.nodes, &mut self.expanded);
255        }
256        cx.observe(signal.entity(), |this, observed, cx| {
257            this.nodes = observed.read(cx).clone();
258            // `default_expanded(true)` promises expand-all for nodes assigned
259            // later too, so new branches join the expanded set here.
260            if this.expand_all {
261                collect_branch_ids(&this.nodes, &mut this.expanded);
262            }
263            cx.notify();
264        })
265        .detach();
266        self
267    }
268
269    /// Expand the branch with this id (construction-time; users toggle live).
270    pub fn expand(mut self, id: impl Into<SharedString>) -> Self {
271        self.expanded.insert(id.into());
272        self
273    }
274
275    /// Collapse the branch with this id.
276    pub fn collapse(mut self, id: impl Into<SharedString>) -> Self {
277        self.expanded.remove(&id.into());
278        self
279    }
280
281    /// Start with every branch expanded. Applies to the current nodes and to
282    /// nodes assigned later via [`nodes`](Self::nodes) / [`bind_nodes`](Self::bind_nodes).
283    pub fn default_expanded(mut self, expanded: bool) -> Self {
284        self.expand_all = expanded;
285        if expanded {
286            collect_branch_ids(&self.nodes, &mut self.expanded);
287        }
288        self
289    }
290
291    /// The ids of every expanded branch, sorted for determinism.
292    pub fn expanded_ids(&self) -> Vec<SharedString> {
293        let mut ids: Vec<SharedString> = self.expanded.iter().cloned().collect();
294        ids.sort();
295        ids
296    }
297
298    /// The id of the selected node, if any.
299    pub fn selected_id(&self) -> Option<SharedString> {
300        self.selected.clone()
301    }
302
303    /// Move the selection, emit, repaint. No-op when already selected.
304    fn select(&mut self, id: SharedString, cx: &mut Context<Self>) {
305        if self.selected.as_ref() == Some(&id) {
306            return;
307        }
308        self.selected = Some(id.clone());
309        cx.emit(TreeViewEvent::Selected(id));
310        cx.notify();
311    }
312
313    /// Flip a branch open/closed.
314    fn toggle(&mut self, id: SharedString, cx: &mut Context<Self>) {
315        let open = !self.expanded.contains(&id);
316        self.set_expanded(id, open, cx);
317    }
318
319    /// Set a branch's expansion, emit, repaint. No-op when unchanged.
320    fn set_expanded(&mut self, id: SharedString, open: bool, cx: &mut Context<Self>) {
321        let changed = if open {
322            self.expanded.insert(id.clone())
323        } else {
324            self.expanded.remove(&id)
325        };
326        if changed {
327            cx.emit(TreeViewEvent::Toggled(id, open));
328            cx.notify();
329        }
330    }
331
332    /// Carry out a [`KeyMove`] against the current visible rows.
333    fn apply(&mut self, mv: KeyMove, rows: &[VisibleRow], cx: &mut Context<Self>) {
334        match mv {
335            KeyMove::To(i) => self.select(rows[i].id.clone(), cx),
336            KeyMove::Set(i, open) => self.set_expanded(rows[i].id.clone(), open, cx),
337            KeyMove::None => {}
338        }
339    }
340
341    fn on_key(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
342        let rows = visible(&self.nodes, &self.expanded);
343        if rows.is_empty() {
344            return;
345        }
346        let current = self
347            .selected
348            .as_ref()
349            .and_then(|id| rows.iter().position(|row| &row.id == id));
350
351        let handled = match event.keystroke.key.as_str() {
352            "down" => {
353                if let Some(i) = step_down(rows.len(), current) {
354                    self.select(rows[i].id.clone(), cx);
355                }
356                true
357            }
358            "up" => {
359                if let Some(i) = step_up(rows.len(), current) {
360                    self.select(rows[i].id.clone(), cx);
361                }
362                true
363            }
364            "right" => match current {
365                Some(i) => {
366                    self.apply(step_right(&rows, i), &rows, cx);
367                    true
368                }
369                None => false,
370            },
371            "left" => match current {
372                Some(i) => {
373                    self.apply(step_left(&rows, i), &rows, cx);
374                    true
375                }
376                None => false,
377            },
378            "enter" => match self.selected.clone() {
379                Some(id) => {
380                    cx.emit(TreeViewEvent::Activated(id));
381                    true
382                }
383                None => false,
384            },
385            _ => false,
386        };
387        if handled {
388            cx.stop_propagation();
389        }
390    }
391}
392
393impl Render for TreeView {
394    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
395        let t = theme(cx);
396        let text = t.text().hsla();
397        let dimmed = t.dimmed().hsla();
398        let accent = t.primary().hsla();
399        let surface_hover = t.surface_hover().hsla();
400        let selected_bg = t.primary().alpha(0.12);
401        let indent = t.spacing(Size::Md);
402        let radius = t.radius(Size::Sm);
403        let font = t.font_size(Size::Sm);
404
405        let rows = visible(&self.nodes, &self.expanded);
406        let selected = self.selected.clone();
407
408        let mut root = div()
409            .id("guise-treeview")
410            .track_focus(&self.focus)
411            .on_key_down(cx.listener(Self::on_key))
412            .on_mouse_down(
413                MouseButton::Left,
414                cx.listener(|this, _ev, window, cx| {
415                    window.focus(&this.focus);
416                    cx.notify();
417                }),
418            )
419            .flex()
420            .flex_col()
421            .gap(px(2.0));
422
423        for (i, row) in rows.into_iter().enumerate() {
424            let is_selected = selected.as_ref() == Some(&row.id);
425            let is_branch = row.is_branch;
426            let id = row.id.clone();
427            let hover_bg = if is_selected {
428                selected_bg
429            } else {
430                surface_hover
431            };
432
433            // Fixed-width chevron cell so branch and leaf labels align.
434            let mut chevron = div()
435                .w(px(16.0))
436                .flex()
437                .items_center()
438                .justify_center()
439                .text_color(dimmed);
440            if is_branch {
441                chevron = chevron.child(SharedString::new_static(if row.expanded {
442                    IconName::ChevronDown.glyph()
443                } else {
444                    IconName::ChevronRight.glyph()
445                }));
446            }
447
448            let fallback = if is_branch {
449                IconName::Menu
450            } else {
451                IconName::Dot
452            };
453            let glyph = row.icon.unwrap_or(fallback);
454            let icon = div()
455                .text_color(if is_selected { accent } else { dimmed })
456                .child(Icon::new(glyph).size(Size::Xs));
457
458            let mut el = div()
459                .id(("guise-tree-row", i))
460                .flex()
461                .items_center()
462                .gap(px(6.0))
463                .pl(px(6.0 + indent * row.depth as f32))
464                .pr(px(8.0))
465                .py(px(4.0))
466                .rounded(px(radius))
467                .text_size(px(font))
468                .text_color(text)
469                .hover(move |s| s.bg(hover_bg))
470                .child(chevron)
471                .child(icon)
472                .child(row.label.clone())
473                .on_click(cx.listener(move |this, ev: &ClickEvent, _window, cx| {
474                    this.select(id.clone(), cx);
475                    if ev.click_count() > 1 {
476                        cx.emit(TreeViewEvent::Activated(id.clone()));
477                    } else if is_branch {
478                        this.toggle(id.clone(), cx);
479                    }
480                }));
481            if is_selected {
482                el = el.bg(selected_bg);
483            }
484            root = root.child(el);
485        }
486
487        root
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn sample() -> Vec<TreeNode> {
496        vec![
497            TreeNode::new("src", "src")
498                .child(TreeNode::new("main", "main.rs"))
499                .child(TreeNode::new("data", "data").child(TreeNode::new("tree", "tree.rs"))),
500            TreeNode::new("readme", "README.md"),
501        ]
502    }
503
504    fn expanded(ids: &[&'static str]) -> HashSet<SharedString> {
505        ids.iter().map(|id| SharedString::from(*id)).collect()
506    }
507
508    fn ids(rows: &[VisibleRow]) -> Vec<&str> {
509        rows.iter().map(|row| row.id.as_ref()).collect()
510    }
511
512    #[test]
513    fn collapsed_tree_shows_only_roots() {
514        let rows = visible(&sample(), &expanded(&[]));
515        assert_eq!(ids(&rows), ["src", "readme"]);
516        assert!(rows[0].is_branch && !rows[0].expanded);
517        assert!(!rows[1].is_branch);
518    }
519
520    #[test]
521    fn expanded_branches_flatten_depth_first() {
522        let rows = visible(&sample(), &expanded(&["src", "data"]));
523        assert_eq!(ids(&rows), ["src", "main", "data", "tree", "readme"]);
524        let depths: Vec<usize> = rows.iter().map(|row| row.depth).collect();
525        assert_eq!(depths, [0, 1, 1, 2, 0]);
526        assert!(rows[2].expanded);
527    }
528
529    #[test]
530    fn collapsed_parent_hides_expanded_descendants() {
531        // "data" is expanded but its parent "src" is not, so it stays hidden.
532        let rows = visible(&sample(), &expanded(&["data"]));
533        assert_eq!(ids(&rows), ["src", "readme"]);
534    }
535
536    #[test]
537    fn up_and_down_clamp_at_the_edges() {
538        assert_eq!(step_down(3, None), Some(0));
539        assert_eq!(step_down(3, Some(1)), Some(2));
540        assert_eq!(step_down(3, Some(2)), Some(2));
541        assert_eq!(step_up(3, None), Some(2));
542        assert_eq!(step_up(3, Some(1)), Some(0));
543        assert_eq!(step_up(3, Some(0)), Some(0));
544        assert_eq!(step_down(0, None), None);
545        assert_eq!(step_up(0, Some(1)), None);
546    }
547
548    #[test]
549    fn right_expands_then_steps_into_the_branch() {
550        let closed = visible(&sample(), &expanded(&[]));
551        assert_eq!(step_right(&closed, 0), KeyMove::Set(0, true));
552
553        let open = visible(&sample(), &expanded(&["src"]));
554        assert_eq!(step_right(&open, 0), KeyMove::To(1));
555        // Leaf rows don't react to right.
556        assert_eq!(step_right(&open, 1), KeyMove::None);
557    }
558
559    #[test]
560    fn left_collapses_then_walks_to_the_parent() {
561        let rows = visible(&sample(), &expanded(&["src", "data"]));
562        // Expanded branch collapses in place.
563        assert_eq!(step_left(&rows, 0), KeyMove::Set(0, false));
564        // A child moves to its parent, skipping same-depth siblings.
565        assert_eq!(step_left(&rows, 1), KeyMove::To(0));
566        assert_eq!(step_left(&rows, 3), KeyMove::To(2));
567        // A collapsed root has nowhere to go.
568        assert_eq!(step_left(&rows, 4), KeyMove::None);
569    }
570
571    #[test]
572    fn branch_ids_cover_nested_branches_only() {
573        let mut out = HashSet::new();
574        collect_branch_ids(&sample(), &mut out);
575        assert_eq!(out, expanded(&["src", "data"]));
576    }
577}