gpui_component/
tree.rs

1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4    div, prelude::FluentBuilder as _, uniform_list, App, Context, ElementId, Entity, FocusHandle,
5    InteractiveElement as _, IntoElement, KeyBinding, ListSizingBehavior, MouseButton,
6    ParentElement, Render, RenderOnce, SharedString, StyleRefinement, Styled,
7    UniformListScrollHandle, Window,
8};
9
10use crate::{
11    actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp},
12    scroll::{Scrollbar, ScrollbarState},
13    ListItem, StyledExt,
14};
15
16const CONTEXT: &str = "Tree";
17pub(crate) fn init(cx: &mut App) {
18    cx.bind_keys([
19        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
20        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
21        KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
22        KeyBinding::new("right", SelectRight, Some(CONTEXT)),
23    ]);
24}
25
26/// Create a [`Tree`].
27///
28/// # Arguments
29///
30/// * `state` - The shared state managing the tree items.
31/// * `render_item` - A closure to render each tree item.
32///
33/// ```ignore
34/// let state = cx.new(|_| {
35///     TreeState::new().items(vec![
36///         TreeItem::new("src")
37///             .child(TreeItem::new("lib.rs"),
38///         TreeItem::new("Cargo.toml"),
39///         TreeItem::new("README.md"),
40///     ])
41/// });
42///
43/// tree(&state, |ix, entry, selected, window, cx| {
44///     div().px(px(16.) * entry.depth()).child(item.label.clone())
45/// })
46/// ```
47pub fn tree<R>(state: &Entity<TreeState>, render_item: R) -> Tree
48where
49    R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
50{
51    Tree::new(state, render_item)
52}
53
54struct TreeItemState {
55    expanded: bool,
56    disabled: bool,
57}
58
59/// A tree item with a label, children, and an expanded state.
60#[derive(Clone)]
61pub struct TreeItem {
62    pub id: SharedString,
63    pub label: SharedString,
64    pub children: Vec<TreeItem>,
65    state: Rc<RefCell<TreeItemState>>,
66}
67
68/// A flat representation of a tree item with its depth.
69#[derive(Clone)]
70pub struct TreeEntry {
71    item: TreeItem,
72    depth: usize,
73}
74
75impl TreeEntry {
76    /// Get the source tree item.
77    #[inline]
78    pub fn item(&self) -> &TreeItem {
79        &self.item
80    }
81
82    /// The depth of this item in the tree.
83    #[inline]
84    pub fn depth(&self) -> usize {
85        self.depth
86    }
87
88    #[inline]
89    fn is_root(&self) -> bool {
90        self.depth == 0
91    }
92
93    /// Whether this item is a folder (has children).
94    #[inline]
95    pub fn is_folder(&self) -> bool {
96        self.item.is_folder()
97    }
98
99    /// Return true if the item is expanded.
100    #[inline]
101    pub fn is_expanded(&self) -> bool {
102        self.item.is_expanded()
103    }
104
105    #[inline]
106    pub fn is_disabled(&self) -> bool {
107        self.item.is_disabled()
108    }
109}
110
111impl TreeItem {
112    /// Create a new tree item with the given label.
113    ///
114    /// - The `id` for you to uniquely identify this item, then later you can use it for selection or other purposes.
115    /// - The `label` is the text to display for this item.
116    ///
117    /// For example, the `id` is the full file path, and the `label` is the file name.
118    ///
119    /// ```ignore
120    /// TreeItem::new("src/ui/button.rs", "button.rs")
121    /// ```
122    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
123        Self {
124            id: id.into(),
125            label: label.into(),
126            children: Vec::new(),
127            state: Rc::new(RefCell::new(TreeItemState {
128                expanded: false,
129                disabled: false,
130            })),
131        }
132    }
133
134    /// Add a child item to this tree item.
135    pub fn child(mut self, child: TreeItem) -> Self {
136        self.children.push(child);
137        self
138    }
139
140    /// Add multiple child items to this tree item.
141    pub fn children(mut self, children: impl Into<Vec<TreeItem>>) -> Self {
142        self.children.extend(children.into());
143        self
144    }
145
146    /// Set expanded state for this tree item.
147    pub fn expanded(self, expanded: bool) -> Self {
148        self.state.borrow_mut().expanded = expanded;
149        self
150    }
151
152    /// Set disabled state for this tree item.
153    pub fn disabled(self, disabled: bool) -> Self {
154        self.state.borrow_mut().disabled = disabled;
155        self
156    }
157
158    /// Whether this item is a folder (has children).
159    #[inline]
160    pub fn is_folder(&self) -> bool {
161        self.children.len() > 0
162    }
163
164    /// Return true if the item is disabled.
165    pub fn is_disabled(&self) -> bool {
166        self.state.borrow().disabled
167    }
168
169    /// Return true if the item is expanded.
170    #[inline]
171    pub fn is_expanded(&self) -> bool {
172        self.state.borrow().expanded
173    }
174}
175
176/// State for managing tree items.
177pub struct TreeState {
178    focus_handle: FocusHandle,
179    entries: Vec<TreeEntry>,
180    scrollbar_state: ScrollbarState,
181    scroll_handle: UniformListScrollHandle,
182    selected_ix: Option<usize>,
183}
184
185impl TreeState {
186    /// Create a new empty tree state.
187    pub fn new(cx: &mut App) -> Self {
188        Self {
189            selected_ix: None,
190            focus_handle: cx.focus_handle(),
191            scrollbar_state: ScrollbarState::default(),
192            scroll_handle: UniformListScrollHandle::default(),
193            entries: Vec::new(),
194        }
195    }
196
197    pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
198        let items = items.into();
199        self.entries.clear();
200        for item in items.into_iter() {
201            self.add_entry(item, 0);
202        }
203        self
204    }
205
206    pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
207        let items = items.into();
208        self.entries.clear();
209        for item in items.into_iter() {
210            self.add_entry(item, 0);
211        }
212        self.selected_ix = None;
213        cx.notify();
214    }
215
216    /// Get the currently selected index, if any.
217    pub fn selected_index(&self) -> Option<usize> {
218        self.selected_ix
219    }
220
221    /// Set the selected index, or `None` to clear selection.
222    pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
223        self.selected_ix = ix;
224        cx.notify();
225    }
226
227    pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) {
228        self.scroll_handle.scroll_to_item(ix, strategy);
229    }
230
231    /// Get the currently selected entry, if any.
232    pub fn selected_entry(&self) -> Option<&TreeEntry> {
233        self.selected_ix.and_then(|ix| self.entries.get(ix))
234    }
235
236    fn add_entry(&mut self, item: TreeItem, depth: usize) {
237        self.entries.push(TreeEntry {
238            item: item.clone(),
239            depth,
240        });
241        if item.is_expanded() {
242            for child in &item.children {
243                self.add_entry(child.clone(), depth + 1);
244            }
245        }
246    }
247
248    fn toggle_expand(&mut self, ix: usize) {
249        let Some(entry) = self.entries.get_mut(ix) else {
250            return;
251        };
252        if !entry.is_folder() {
253            return;
254        }
255
256        entry.item.state.borrow_mut().expanded = !entry.is_expanded();
257        self.rebuild_entries();
258    }
259
260    fn rebuild_entries(&mut self) {
261        let root_items: Vec<TreeItem> = self
262            .entries
263            .iter()
264            .filter(|e| e.is_root())
265            .map(|e| e.item.clone())
266            .collect();
267        self.entries.clear();
268        for item in root_items.into_iter() {
269            self.add_entry(item, 0);
270        }
271    }
272
273    fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
274        if let Some(selected_ix) = self.selected_ix {
275            if let Some(entry) = self.entries.get(selected_ix) {
276                if entry.is_folder() {
277                    self.toggle_expand(selected_ix);
278                    cx.notify();
279                }
280            }
281        }
282    }
283
284    fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
285        if let Some(selected_ix) = self.selected_ix {
286            if let Some(entry) = self.entries.get(selected_ix) {
287                if entry.is_folder() && entry.is_expanded() {
288                    self.toggle_expand(selected_ix);
289                    cx.notify();
290                }
291            }
292        }
293    }
294
295    fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
296        if let Some(selected_ix) = self.selected_ix {
297            if let Some(entry) = self.entries.get(selected_ix) {
298                if entry.is_folder() && !entry.is_expanded() {
299                    self.toggle_expand(selected_ix);
300                    cx.notify();
301                }
302            }
303        }
304    }
305
306    fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
307        let mut selected_ix = self.selected_ix.unwrap_or(0);
308
309        if selected_ix > 0 {
310            selected_ix = selected_ix - 1;
311        } else {
312            selected_ix = self.entries.len().saturating_sub(1);
313        }
314
315        self.selected_ix = Some(selected_ix);
316        self.scroll_handle
317            .scroll_to_item(selected_ix, gpui::ScrollStrategy::Top);
318        cx.notify();
319    }
320
321    fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
322        let mut selected_ix = self.selected_ix.unwrap_or(0);
323        if selected_ix + 1 < self.entries.len() {
324            selected_ix = selected_ix + 1;
325        } else {
326            selected_ix = 0;
327        }
328
329        self.selected_ix = Some(selected_ix);
330        self.scroll_handle
331            .scroll_to_item(selected_ix, gpui::ScrollStrategy::Bottom);
332        cx.notify();
333    }
334}
335
336impl Render for TreeState {
337    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
338        div()
339    }
340}
341
342/// A tree view element that displays hierarchical data.
343#[derive(IntoElement)]
344pub struct Tree {
345    id: ElementId,
346    state: Entity<TreeState>,
347    style: StyleRefinement,
348    render_item: Rc<dyn Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem>,
349}
350
351impl Tree {
352    pub fn new<R>(state: &Entity<TreeState>, render_item: R) -> Self
353    where
354        R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
355    {
356        Self {
357            id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
358            state: state.clone(),
359            style: StyleRefinement::default(),
360            render_item: Rc::new(move |ix, item, selected, window, app| {
361                render_item(ix, item, selected, window, app)
362            }),
363        }
364    }
365
366    fn on_entry_click(state: &Entity<TreeState>, ix: usize, _: &mut Window, cx: &mut App) {
367        state.update(cx, |state, cx| {
368            state.selected_ix = Some(ix);
369            state.toggle_expand(ix);
370            cx.notify();
371        })
372    }
373}
374
375impl Styled for Tree {
376    fn style(&mut self) -> &mut StyleRefinement {
377        &mut self.style
378    }
379}
380
381impl RenderOnce for Tree {
382    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
383        let tree_state = self.state.read(cx);
384        let render_item = self.render_item.clone();
385
386        div()
387            .id(self.id)
388            .key_context(CONTEXT)
389            .track_focus(&tree_state.focus_handle)
390            .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
391            .on_action(window.listener_for(&self.state, TreeState::on_action_left))
392            .on_action(window.listener_for(&self.state, TreeState::on_action_right))
393            .on_action(window.listener_for(&self.state, TreeState::on_action_up))
394            .on_action(window.listener_for(&self.state, TreeState::on_action_down))
395            .size_full()
396            .child(
397                uniform_list("entries", tree_state.entries.len(), {
398                    let selected_ix = tree_state.selected_ix;
399                    let entries = tree_state.entries.clone();
400                    let state = self.state.clone();
401                    move |visible_range, window, cx| {
402                        let mut items = Vec::with_capacity(visible_range.len());
403                        for ix in visible_range {
404                            let entry = &entries[ix];
405                            let selected = Some(ix) == selected_ix;
406                            let item = (render_item)(ix, entry, selected, window, cx);
407
408                            let el = div()
409                                .id(ix)
410                                .child(item.disabled(entry.item().is_disabled()).selected(selected))
411                                .when(!entry.item().is_disabled(), |this| {
412                                    this.on_mouse_down(MouseButton::Left, {
413                                        let state = state.clone();
414                                        move |_, window, cx| {
415                                            Self::on_entry_click(&state, ix, window, cx);
416                                        }
417                                    })
418                                });
419
420                            items.push(el)
421                        }
422
423                        items
424                    }
425                })
426                .flex_grow()
427                .size_full()
428                .track_scroll(tree_state.scroll_handle.clone())
429                .with_sizing_behavior(ListSizingBehavior::Auto)
430                .into_any_element(),
431            )
432            .refine_style(&self.style)
433            .relative()
434            .child(
435                div()
436                    .absolute()
437                    .top_0()
438                    .right_0()
439                    .bottom_0()
440                    .w(Scrollbar::width())
441                    .child(Scrollbar::vertical(
442                        &tree_state.scrollbar_state,
443                        &tree_state.scroll_handle,
444                    )),
445            )
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use indoc::indoc;
452
453    use crate::TreeState;
454    use gpui::AppContext as _;
455
456    fn assert_entries(entries: &Vec<super::TreeEntry>, expected: &str) {
457        let actual: Vec<String> = entries
458            .iter()
459            .map(|e| {
460                let mut s = String::new();
461                s.push_str(&"    ".repeat(e.depth));
462                s.push_str(e.item().label.as_str());
463                s
464            })
465            .collect();
466        let actual = actual.join("\n");
467        assert_eq!(actual.trim(), expected.trim());
468    }
469
470    #[gpui::test]
471    fn test_tree_entry(cx: &mut gpui::TestAppContext) {
472        use super::TreeItem;
473
474        let items = vec![
475            TreeItem::new("src", "src")
476                .expanded(true)
477                .child(
478                    TreeItem::new("src/ui", "ui")
479                        .expanded(true)
480                        .child(TreeItem::new("src/ui/button.rs", "button.rs"))
481                        .child(TreeItem::new("src/ui/icon.rs", "icon.rs"))
482                        .child(TreeItem::new("src/ui/mod.rs", "mod.rs")),
483                )
484                .child(TreeItem::new("src/lib.rs", "lib.rs")),
485            TreeItem::new("Cargo.toml", "Cargo.toml"),
486            TreeItem::new("Cargo.lock", "Cargo.lock").disabled(true),
487            TreeItem::new("README.md", "README.md"),
488        ];
489
490        let state = cx.new(|cx| TreeState::new(cx).items(items));
491        state.update(cx, |state, _| {
492            assert_entries(
493                &state.entries,
494                indoc! {
495                    r#"
496                src
497                    ui
498                        button.rs
499                        icon.rs
500                        mod.rs
501                    lib.rs
502                Cargo.toml
503                Cargo.lock
504                README.md
505                "#
506                },
507            );
508
509            let entry = state.entries.get(0).unwrap();
510            assert_eq!(entry.depth(), 0);
511            assert_eq!(entry.is_root(), true);
512            assert_eq!(entry.is_folder(), true);
513            assert_eq!(entry.is_expanded(), true);
514
515            let entry = state.entries.get(1).unwrap();
516            assert_eq!(entry.depth(), 1);
517            assert_eq!(entry.is_root(), false);
518            assert_eq!(entry.is_folder(), true);
519            assert_eq!(entry.is_expanded(), true);
520            assert_eq!(entry.item().label.as_str(), "ui");
521
522            state.toggle_expand(1);
523            let entry = state.entries.get(1).unwrap();
524            assert_eq!(entry.is_expanded(), false);
525            assert_entries(
526                &state.entries,
527                indoc! {
528                    r#"
529                src
530                    ui
531                    lib.rs
532                Cargo.toml
533                Cargo.lock
534                README.md
535                "#
536                },
537            );
538        })
539    }
540}