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