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