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| this.render_rows(range, cx)),
548        )
549        .h(px(height))
550        .w_full()
551        .track_scroll(self.scroll.clone()),
552      )
553    } else {
554      root.gap(px(2.0)).children(self.render_rows(0..count, cx))
555    };
556
557    element.probe("TreeView")
558  }
559}
560
561#[cfg(test)]
562mod tests {
563  use super::*;
564
565  fn sample() -> Vec<TreeNode> {
566    vec![
567      TreeNode::new("src", "src")
568        .child(TreeNode::new("main", "main.rs"))
569        .child(TreeNode::new("data", "data").child(TreeNode::new("tree", "tree.rs"))),
570      TreeNode::new("readme", "README.md"),
571    ]
572  }
573
574  fn expanded(ids: &[&'static str]) -> HashSet<SharedString> {
575    ids.iter().map(|id| SharedString::from(*id)).collect()
576  }
577
578  fn ids(rows: &[VisibleRow]) -> Vec<&str> {
579    rows.iter().map(|row| row.id.as_ref()).collect()
580  }
581
582  #[test]
583  fn collapsed_tree_shows_only_roots() {
584    let rows = visible(&sample(), &expanded(&[]));
585    assert_eq!(ids(&rows), ["src", "readme"]);
586    assert!(rows[0].is_branch && !rows[0].expanded);
587    assert!(!rows[1].is_branch);
588  }
589
590  #[test]
591  fn expanded_branches_flatten_depth_first() {
592    let rows = visible(&sample(), &expanded(&["src", "data"]));
593    assert_eq!(ids(&rows), ["src", "main", "data", "tree", "readme"]);
594    let depths: Vec<usize> = rows.iter().map(|row| row.depth).collect();
595    assert_eq!(depths, [0, 1, 1, 2, 0]);
596    assert!(rows[2].expanded);
597  }
598
599  #[test]
600  fn collapsed_parent_hides_expanded_descendants() {
601    // "data" is expanded but its parent "src" is not, so it stays hidden.
602    let rows = visible(&sample(), &expanded(&["data"]));
603    assert_eq!(ids(&rows), ["src", "readme"]);
604  }
605
606  #[test]
607  fn up_and_down_clamp_at_the_edges() {
608    assert_eq!(step_down(3, None), Some(0));
609    assert_eq!(step_down(3, Some(1)), Some(2));
610    assert_eq!(step_down(3, Some(2)), Some(2));
611    assert_eq!(step_up(3, None), Some(2));
612    assert_eq!(step_up(3, Some(1)), Some(0));
613    assert_eq!(step_up(3, Some(0)), Some(0));
614    assert_eq!(step_down(0, None), None);
615    assert_eq!(step_up(0, Some(1)), None);
616  }
617
618  #[test]
619  fn right_expands_then_steps_into_the_branch() {
620    let closed = visible(&sample(), &expanded(&[]));
621    assert_eq!(step_right(&closed, 0), KeyMove::Set(0, true));
622
623    let open = visible(&sample(), &expanded(&["src"]));
624    assert_eq!(step_right(&open, 0), KeyMove::To(1));
625    // Leaf rows don't react to right.
626    assert_eq!(step_right(&open, 1), KeyMove::None);
627  }
628
629  #[test]
630  fn left_collapses_then_walks_to_the_parent() {
631    let rows = visible(&sample(), &expanded(&["src", "data"]));
632    // Expanded branch collapses in place.
633    assert_eq!(step_left(&rows, 0), KeyMove::Set(0, false));
634    // A child moves to its parent, skipping same-depth siblings.
635    assert_eq!(step_left(&rows, 1), KeyMove::To(0));
636    assert_eq!(step_left(&rows, 3), KeyMove::To(2));
637    // A collapsed root has nowhere to go.
638    assert_eq!(step_left(&rows, 4), KeyMove::None);
639  }
640
641  #[test]
642  fn branch_ids_cover_nested_branches_only() {
643    let mut out = HashSet::new();
644    collect_branch_ids(&sample(), &mut out);
645    assert_eq!(out, expanded(&["src", "data"]));
646  }
647}