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///     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    render_item: Rc<dyn Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem>,
185}
186
187impl TreeState {
188    /// Create a new empty tree state.
189    pub fn new(cx: &mut App) -> Self {
190        Self {
191            selected_ix: None,
192            focus_handle: cx.focus_handle(),
193            scrollbar_state: ScrollbarState::default(),
194            scroll_handle: UniformListScrollHandle::default(),
195            entries: Vec::new(),
196            render_item: Rc::new(|_, _, _, _, _| ListItem::new(0)),
197        }
198    }
199
200    /// Set the tree items.
201    pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
202        let items = items.into();
203        self.entries.clear();
204        for item in items.into_iter() {
205            self.add_entry(item, 0);
206        }
207        self
208    }
209
210    /// Set the tree items.
211    pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
212        let items = items.into();
213        self.entries.clear();
214        for item in items.into_iter() {
215            self.add_entry(item, 0);
216        }
217        self.selected_ix = None;
218        cx.notify();
219    }
220
221    /// Get the currently selected index, if any.
222    pub fn selected_index(&self) -> Option<usize> {
223        self.selected_ix
224    }
225
226    /// Set the selected index, or `None` to clear selection.
227    pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
228        self.selected_ix = ix;
229        cx.notify();
230    }
231
232    pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) {
233        self.scroll_handle.scroll_to_item(ix, strategy);
234    }
235
236    /// Get the currently selected entry, if any.
237    pub fn selected_entry(&self) -> Option<&TreeEntry> {
238        self.selected_ix.and_then(|ix| self.entries.get(ix))
239    }
240
241    fn add_entry(&mut self, item: TreeItem, depth: usize) {
242        self.entries.push(TreeEntry {
243            item: item.clone(),
244            depth,
245        });
246        if item.is_expanded() {
247            for child in &item.children {
248                self.add_entry(child.clone(), depth + 1);
249            }
250        }
251    }
252
253    fn toggle_expand(&mut self, ix: usize) {
254        let Some(entry) = self.entries.get_mut(ix) else {
255            return;
256        };
257        if !entry.is_folder() {
258            return;
259        }
260
261        entry.item.state.borrow_mut().expanded = !entry.is_expanded();
262        self.rebuild_entries();
263    }
264
265    fn rebuild_entries(&mut self) {
266        let root_items: Vec<TreeItem> = self
267            .entries
268            .iter()
269            .filter(|e| e.is_root())
270            .map(|e| e.item.clone())
271            .collect();
272        self.entries.clear();
273        for item in root_items.into_iter() {
274            self.add_entry(item, 0);
275        }
276    }
277
278    fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
279        if let Some(selected_ix) = self.selected_ix {
280            if let Some(entry) = self.entries.get(selected_ix) {
281                if entry.is_folder() {
282                    self.toggle_expand(selected_ix);
283                    cx.notify();
284                }
285            }
286        }
287    }
288
289    fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
290        if let Some(selected_ix) = self.selected_ix {
291            if let Some(entry) = self.entries.get(selected_ix) {
292                if entry.is_folder() && entry.is_expanded() {
293                    self.toggle_expand(selected_ix);
294                    cx.notify();
295                }
296            }
297        }
298    }
299
300    fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
301        if let Some(selected_ix) = self.selected_ix {
302            if let Some(entry) = self.entries.get(selected_ix) {
303                if entry.is_folder() && !entry.is_expanded() {
304                    self.toggle_expand(selected_ix);
305                    cx.notify();
306                }
307            }
308        }
309    }
310
311    fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
312        let mut selected_ix = self.selected_ix.unwrap_or(0);
313
314        if selected_ix > 0 {
315            selected_ix = selected_ix - 1;
316        } else {
317            selected_ix = self.entries.len().saturating_sub(1);
318        }
319
320        self.selected_ix = Some(selected_ix);
321        self.scroll_handle
322            .scroll_to_item(selected_ix, gpui::ScrollStrategy::Top);
323        cx.notify();
324    }
325
326    fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
327        let mut selected_ix = self.selected_ix.unwrap_or(0);
328        if selected_ix + 1 < self.entries.len() {
329            selected_ix = selected_ix + 1;
330        } else {
331            selected_ix = 0;
332        }
333
334        self.selected_ix = Some(selected_ix);
335        self.scroll_handle
336            .scroll_to_item(selected_ix, gpui::ScrollStrategy::Bottom);
337        cx.notify();
338    }
339
340    fn on_entry_click(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) {
341        self.selected_ix = Some(ix);
342        self.toggle_expand(ix);
343        cx.notify();
344    }
345}
346
347impl Render for TreeState {
348    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
349        let render_item = self.render_item.clone();
350
351        div()
352            .id("tree-state")
353            .size_full()
354            .relative()
355            .child(
356                uniform_list("entries", self.entries.len(), {
357                    cx.processor(move |state, visible_range: Range<usize>, window, cx| {
358                        let mut items = Vec::with_capacity(visible_range.len());
359                        for ix in visible_range {
360                            let entry = &state.entries[ix];
361                            let selected = Some(ix) == state.selected_ix;
362                            let item = (render_item)(ix, entry, selected, window, cx);
363
364                            let el = div()
365                                .id(ix)
366                                .child(item.disabled(entry.item().is_disabled()).selected(selected))
367                                .when(!entry.item().is_disabled(), |this| {
368                                    this.on_mouse_down(
369                                        MouseButton::Left,
370                                        cx.listener({
371                                            move |this, _, window, cx| {
372                                                this.on_entry_click(ix, window, cx);
373                                            }
374                                        }),
375                                    )
376                                });
377
378                            items.push(el)
379                        }
380
381                        items
382                    })
383                })
384                .flex_grow()
385                .size_full()
386                .track_scroll(self.scroll_handle.clone())
387                .with_sizing_behavior(ListSizingBehavior::Auto)
388                .into_any_element(),
389            )
390            .child(
391                div()
392                    .absolute()
393                    .top_0()
394                    .right_0()
395                    .bottom_0()
396                    .w(Scrollbar::width())
397                    .child(Scrollbar::vertical(
398                        &self.scrollbar_state,
399                        &self.scroll_handle,
400                    )),
401            )
402    }
403}
404
405/// A tree view element that displays hierarchical data.
406#[derive(IntoElement)]
407pub struct Tree {
408    id: ElementId,
409    state: Entity<TreeState>,
410    style: StyleRefinement,
411    render_item: Rc<dyn Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem>,
412}
413
414impl Tree {
415    pub fn new<R>(state: &Entity<TreeState>, render_item: R) -> Self
416    where
417        R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
418    {
419        Self {
420            id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
421            state: state.clone(),
422            style: StyleRefinement::default(),
423            render_item: Rc::new(move |ix, item, selected, window, app| {
424                render_item(ix, item, selected, window, app)
425            }),
426        }
427    }
428}
429
430impl Styled for Tree {
431    fn style(&mut self) -> &mut StyleRefinement {
432        &mut self.style
433    }
434}
435
436impl RenderOnce for Tree {
437    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
438        let focus_handle = self.state.read(cx).focus_handle.clone();
439
440        self.state
441            .update(cx, |state, _| state.render_item = self.render_item);
442
443        div()
444            .id(self.id)
445            .key_context(CONTEXT)
446            .track_focus(&focus_handle)
447            .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
448            .on_action(window.listener_for(&self.state, TreeState::on_action_left))
449            .on_action(window.listener_for(&self.state, TreeState::on_action_right))
450            .on_action(window.listener_for(&self.state, TreeState::on_action_up))
451            .on_action(window.listener_for(&self.state, TreeState::on_action_down))
452            .size_full()
453            .child(self.state)
454            .refine_style(&self.style)
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use indoc::indoc;
461
462    use super::TreeState;
463    use gpui::AppContext as _;
464
465    fn assert_entries(entries: &Vec<super::TreeEntry>, expected: &str) {
466        let actual: Vec<String> = entries
467            .iter()
468            .map(|e| {
469                let mut s = String::new();
470                s.push_str(&"    ".repeat(e.depth));
471                s.push_str(e.item().label.as_str());
472                s
473            })
474            .collect();
475        let actual = actual.join("\n");
476        assert_eq!(actual.trim(), expected.trim());
477    }
478
479    #[gpui::test]
480    fn test_tree_entry(cx: &mut gpui::TestAppContext) {
481        use super::TreeItem;
482
483        let items = vec![
484            TreeItem::new("src", "src")
485                .expanded(true)
486                .child(
487                    TreeItem::new("src/ui", "ui")
488                        .expanded(true)
489                        .child(TreeItem::new("src/ui/button.rs", "button.rs"))
490                        .child(TreeItem::new("src/ui/icon.rs", "icon.rs"))
491                        .child(TreeItem::new("src/ui/mod.rs", "mod.rs")),
492                )
493                .child(TreeItem::new("src/lib.rs", "lib.rs")),
494            TreeItem::new("Cargo.toml", "Cargo.toml"),
495            TreeItem::new("Cargo.lock", "Cargo.lock").disabled(true),
496            TreeItem::new("README.md", "README.md"),
497        ];
498
499        let state = cx.new(|cx| TreeState::new(cx).items(items));
500        state.update(cx, |state, _| {
501            assert_entries(
502                &state.entries,
503                indoc! {
504                    r#"
505                src
506                    ui
507                        button.rs
508                        icon.rs
509                        mod.rs
510                    lib.rs
511                Cargo.toml
512                Cargo.lock
513                README.md
514                "#
515                },
516            );
517
518            let entry = state.entries.get(0).unwrap();
519            assert_eq!(entry.depth(), 0);
520            assert_eq!(entry.is_root(), true);
521            assert_eq!(entry.is_folder(), true);
522            assert_eq!(entry.is_expanded(), true);
523
524            let entry = state.entries.get(1).unwrap();
525            assert_eq!(entry.depth(), 1);
526            assert_eq!(entry.is_root(), false);
527            assert_eq!(entry.is_folder(), true);
528            assert_eq!(entry.is_expanded(), true);
529            assert_eq!(entry.item().label.as_str(), "ui");
530
531            state.toggle_expand(1);
532            let entry = state.entries.get(1).unwrap();
533            assert_eq!(entry.is_expanded(), false);
534            assert_entries(
535                &state.entries,
536                indoc! {
537                    r#"
538                src
539                    ui
540                    lib.rs
541                Cargo.toml
542                Cargo.lock
543                README.md
544                "#
545                },
546            );
547        })
548    }
549}