gpui_component/
tree.rs

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