Skip to main content

gpui_base/
tree.rs

1use crate::TestSupportExt as _;
2use gpui::StatefulInteractiveElement as _;
3use std::{cell::RefCell, ops::Range, rc::Rc};
4
5use gpui::{
6    AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, InteractiveElement,
7    IntoElement, KeyBinding, MouseButton, ParentElement, Render, RenderOnce, SharedString,
8    StyleRefinement, Styled, UniformListScrollHandle, Window, div, prelude::FluentBuilder as _,
9    uniform_list,
10};
11
12use crate::{
13    actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp},
14    styled::StyledExt as _,
15};
16
17const CONTEXT: &str = "Tree";
18
19#[doc(hidden)]
20pub fn init(cx: &mut App) {
21    cx.bind_keys([
22        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
23        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
24        KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
25        KeyBinding::new("right", SelectRight, Some(CONTEXT)),
26    ]);
27}
28
29#[doc(hidden)]
30pub const fn key_context() -> &'static str {
31    CONTEXT
32}
33
34struct TreeItemState {
35    expanded: bool,
36    disabled: bool,
37}
38
39/// A tree item with a stable id, display label, children, and shared state.
40#[derive(Clone)]
41pub struct TreeItem {
42    pub id: SharedString,
43    pub label: SharedString,
44    pub children: Vec<TreeItem>,
45    state: Rc<RefCell<TreeItemState>>,
46}
47
48/// A flat representation of a tree item with its depth.
49#[derive(Clone)]
50pub struct TreeEntry {
51    item: TreeItem,
52    depth: usize,
53}
54
55impl TreeEntry {
56    pub fn new(item: TreeItem, depth: usize) -> Self {
57        Self { item, depth }
58    }
59
60    #[inline]
61    pub fn item(&self) -> &TreeItem {
62        &self.item
63    }
64
65    #[inline]
66    pub fn depth(&self) -> usize {
67        self.depth
68    }
69
70    #[inline]
71    pub fn is_root(&self) -> bool {
72        self.depth == 0
73    }
74
75    #[inline]
76    pub fn is_folder(&self) -> bool {
77        self.item.is_folder()
78    }
79
80    #[inline]
81    pub fn is_expanded(&self) -> bool {
82        self.item.is_expanded()
83    }
84
85    #[inline]
86    pub fn is_disabled(&self) -> bool {
87        self.item.is_disabled()
88    }
89}
90
91/// Event emitted by a tree when user-visible expansion state changes.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub enum TreeEvent {
94    Expanded(SharedString),
95    Collapsed(SharedString),
96}
97
98impl TreeItem {
99    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
100        Self {
101            id: id.into(),
102            label: label.into(),
103            children: Vec::new(),
104            state: Rc::new(RefCell::new(TreeItemState {
105                expanded: false,
106                disabled: false,
107            })),
108        }
109    }
110
111    pub fn child(mut self, child: TreeItem) -> Self {
112        self.children.push(child);
113        self
114    }
115
116    pub fn children(mut self, children: impl IntoIterator<Item = TreeItem>) -> Self {
117        self.children.extend(children);
118        self
119    }
120
121    pub fn expanded(self, expanded: bool) -> Self {
122        self.state.borrow_mut().expanded = expanded;
123        self
124    }
125
126    pub fn disabled(self, disabled: bool) -> Self {
127        self.state.borrow_mut().disabled = disabled;
128        self
129    }
130
131    #[inline]
132    pub fn is_folder(&self) -> bool {
133        !self.children.is_empty()
134    }
135
136    pub fn is_disabled(&self) -> bool {
137        self.state.borrow().disabled
138    }
139
140    #[inline]
141    pub fn is_expanded(&self) -> bool {
142        self.state.borrow().expanded
143    }
144
145    /// Returns the target's ancestors from nearest parent to root.
146    pub fn ancestors(&self, target_id: &SharedString) -> Option<Vec<TreeItem>> {
147        if self.id == *target_id {
148            return Some(Vec::new());
149        }
150
151        for child in &self.children {
152            if let Some(mut path) = child.ancestors(target_id) {
153                path.push(self.clone());
154                return Some(path);
155            }
156        }
157
158        None
159    }
160}
161
162/// The interaction state supplied while rendering a visible tree entry.
163#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
164pub struct TreeEntryState {
165    selected: bool,
166    right_clicked: bool,
167}
168
169impl TreeEntryState {
170    #[inline]
171    pub fn is_selected(self) -> bool {
172        self.selected
173    }
174
175    #[inline]
176    pub fn is_right_clicked(self) -> bool {
177        self.right_clicked
178    }
179}
180
181type RenderItem = dyn Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement;
182
183/// Behavior and interaction state for a virtualized tree.
184pub struct TreeState {
185    focus_handle: FocusHandle,
186    entries: Vec<TreeEntry>,
187    scroll_handle: UniformListScrollHandle,
188    selected_ix: Option<usize>,
189    right_clicked_ix: Option<usize>,
190    render_item: Rc<RenderItem>,
191    list_style: StyleRefinement,
192}
193
194impl EventEmitter<TreeEvent> for TreeState {}
195
196impl TreeState {
197    pub fn new(cx: &mut App) -> Self {
198        Self {
199            focus_handle: cx.focus_handle(),
200            entries: Vec::new(),
201            scroll_handle: UniformListScrollHandle::default(),
202            selected_ix: None,
203            right_clicked_ix: None,
204            render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
205            list_style: StyleRefinement::default(),
206        }
207    }
208
209    pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
210        self.replace_items(items.into());
211        self
212    }
213
214    pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
215        self.replace_items(items.into());
216        self.selected_ix = None;
217        self.right_clicked_ix = None;
218        cx.notify();
219    }
220
221    pub fn selected_index(&self) -> Option<usize> {
222        self.selected_ix
223    }
224
225    pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
226        self.selected_ix = ix;
227        cx.notify();
228    }
229
230    pub fn set_selected_item(&mut self, item: Option<&TreeItem>, cx: &mut Context<Self>) {
231        if let Some(item) = item {
232            self.selected_ix = self.index_of(&item.id);
233            if self.selected_ix.is_none() {
234                self.expand_ancestors(item.id.clone(), cx);
235                self.selected_ix = self.index_of(&item.id);
236            }
237        } else {
238            self.selected_ix = None;
239        }
240        cx.notify();
241    }
242
243    pub fn selected_item(&self) -> Option<&TreeItem> {
244        self.selected_ix
245            .and_then(|ix| self.entries.get(ix).map(TreeEntry::item))
246    }
247
248    pub fn selected_entry(&self) -> Option<&TreeEntry> {
249        self.selected_ix.and_then(|ix| self.entries.get(ix))
250    }
251
252    pub fn entry(&self, ix: usize) -> Option<&TreeEntry> {
253        self.entries.get(ix)
254    }
255
256    pub fn scroll_handle(&self) -> &UniformListScrollHandle {
257        &self.scroll_handle
258    }
259
260    pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) {
261        self.scroll_handle.scroll_to_item(ix, strategy);
262    }
263
264    pub fn index_of(&self, id: &SharedString) -> Option<usize> {
265        self.entries.iter().position(|entry| &entry.item.id == id)
266    }
267
268    pub fn reveal_item(
269        &mut self,
270        id: &SharedString,
271        strategy: gpui::ScrollStrategy,
272        cx: &mut Context<Self>,
273    ) {
274        self.expand_ancestors(id.clone(), cx);
275        if let Some(ix) = self.index_of(id) {
276            self.scroll_to_item(ix, strategy);
277        }
278    }
279
280    pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
281        self.focus_handle.focus(window, cx);
282    }
283
284    fn replace_items(&mut self, items: Vec<TreeItem>) {
285        self.entries.clear();
286        for item in items {
287            self.add_entry(item, 0);
288        }
289    }
290
291    fn expand_ancestors(&mut self, target_id: SharedString, cx: &mut Context<Self>) {
292        let ancestors = self
293            .entries
294            .iter()
295            .find_map(|entry| entry.item.ancestors(&target_id))
296            .unwrap_or_default();
297
298        if ancestors.is_empty() {
299            return;
300        }
301
302        for ancestor in ancestors.into_iter().rev() {
303            if !ancestor.is_expanded() {
304                ancestor.state.borrow_mut().expanded = true;
305                cx.emit(TreeEvent::Expanded(ancestor.id.clone()));
306            }
307        }
308        self.rebuild_entries();
309    }
310
311    fn add_entry(&mut self, item: TreeItem, depth: usize) {
312        self.entries.push(TreeEntry::new(item.clone(), depth));
313        if item.is_expanded() {
314            for child in &item.children {
315                self.add_entry(child.clone(), depth + 1);
316            }
317        }
318    }
319
320    fn toggle_expand(&mut self, ix: usize, cx: &mut Context<Self>) {
321        let Some(entry) = self.entries.get(ix) else {
322            return;
323        };
324        if !entry.is_folder() {
325            return;
326        }
327
328        let expanded = !entry.is_expanded();
329        let id = entry.item.id.clone();
330        entry.item.state.borrow_mut().expanded = expanded;
331        cx.emit(if expanded {
332            TreeEvent::Expanded(id)
333        } else {
334            TreeEvent::Collapsed(id)
335        });
336        self.right_clicked_ix = None;
337        self.rebuild_entries();
338    }
339
340    fn rebuild_entries(&mut self) {
341        let roots = self
342            .entries
343            .iter()
344            .filter(|entry| entry.is_root())
345            .map(|entry| entry.item.clone())
346            .collect::<Vec<_>>();
347        self.replace_items(roots);
348    }
349
350    fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
351        if self
352            .selected_ix
353            .and_then(|ix| self.entries.get(ix).map(|entry| (ix, entry.is_folder())))
354            .is_some_and(|(ix, is_folder)| {
355                if is_folder {
356                    self.toggle_expand(ix, cx);
357                }
358                is_folder
359            })
360        {
361            cx.notify();
362        }
363    }
364
365    fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
366        if let Some(ix) = self.selected_ix
367            && self
368                .entries
369                .get(ix)
370                .is_some_and(|entry| entry.is_folder() && entry.is_expanded())
371        {
372            self.toggle_expand(ix, cx);
373            cx.notify();
374        }
375    }
376
377    fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
378        if let Some(ix) = self.selected_ix
379            && self
380                .entries
381                .get(ix)
382                .is_some_and(|entry| entry.is_folder() && !entry.is_expanded())
383        {
384            self.toggle_expand(ix, cx);
385            cx.notify();
386        }
387    }
388
389    fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
390        let mut ix = self.selected_ix.unwrap_or(0);
391        ix = ix
392            .checked_sub(1)
393            .unwrap_or_else(|| self.entries.len().saturating_sub(1));
394        self.selected_ix = Some(ix);
395        self.scroll_handle
396            .scroll_to_item(ix, gpui::ScrollStrategy::Top);
397        cx.notify();
398    }
399
400    fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
401        let mut ix = self.selected_ix.unwrap_or(0);
402        ix = if ix + 1 < self.entries.len() {
403            ix + 1
404        } else {
405            0
406        };
407        self.selected_ix = Some(ix);
408        self.scroll_handle
409            .scroll_to_item(ix, gpui::ScrollStrategy::Bottom);
410        cx.notify();
411    }
412
413    fn on_entry_click(&mut self, ix: usize, cx: &mut Context<Self>) {
414        self.selected_ix = Some(ix);
415        self.toggle_expand(ix, cx);
416        cx.notify();
417    }
418}
419
420impl Render for TreeState {
421    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
422        let render_item = self.render_item.clone();
423        uniform_list("entries", self.entries.len(), {
424            cx.processor(move |state, visible_range: Range<usize>, window, cx| {
425                visible_range
426                    .map(|ix| {
427                        let entry = &state.entries[ix];
428                        let entry_state = TreeEntryState {
429                            selected: Some(ix) == state.selected_ix,
430                            right_clicked: Some(ix) == state.right_clicked_ix,
431                        };
432                        div()
433                            .id(ix)
434                            .test_support()
435                            .role(gpui::Role::TreeItem)
436                            .aria_label(entry.item().label.clone())
437                            .aria_selected(entry_state.selected)
438                            .when(entry.is_folder(), |this| {
439                                this.aria_expanded(entry.is_expanded())
440                            })
441                            .child((render_item)(ix, entry, entry_state, window, cx))
442                            .when(!entry.is_disabled(), |this| {
443                                this.on_mouse_down(
444                                    MouseButton::Left,
445                                    cx.listener(move |state, _, _, cx| {
446                                        state.on_entry_click(ix, cx);
447                                    }),
448                                )
449                                .on_mouse_down(
450                                    MouseButton::Right,
451                                    cx.listener(move |state, _, _, cx| {
452                                        state.right_clicked_ix = Some(ix);
453                                        cx.notify();
454                                    }),
455                                )
456                            })
457                    })
458                    .collect()
459            })
460        })
461        .track_scroll(&self.scroll_handle)
462        .refine_style(&self.list_style)
463    }
464}
465
466/// An unstyled, virtualized tree element.
467#[derive(IntoElement)]
468pub struct Tree {
469    id: ElementId,
470    state: Entity<TreeState>,
471    style: StyleRefinement,
472    list_style: StyleRefinement,
473    render_item: Rc<RenderItem>,
474}
475
476impl Tree {
477    pub fn new(state: &Entity<TreeState>) -> Self {
478        Self {
479            id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
480            state: state.clone(),
481            style: StyleRefinement::default(),
482            list_style: StyleRefinement::default(),
483            render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
484        }
485    }
486
487    /// Supplies the application-owned content for each visible entry.
488    pub fn item<R>(mut self, render_item: R) -> Self
489    where
490        R: Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement + 'static,
491    {
492        self.render_item = Rc::new(render_item);
493        self
494    }
495
496    /// Applies caller-owned presentation to the internal virtual list.
497    pub fn list_style(mut self, style: StyleRefinement) -> Self {
498        self.list_style = style;
499        self
500    }
501}
502
503impl Styled for Tree {
504    fn style(&mut self) -> &mut StyleRefinement {
505        &mut self.style
506    }
507}
508
509impl RenderOnce for Tree {
510    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
511        let focus_handle = self.state.read(cx).focus_handle.clone();
512        self.state.update(cx, |state, _| {
513            state.render_item = self.render_item;
514            state.list_style = self.list_style;
515        });
516
517        div()
518            .id(self.id)
519            .test_support()
520            .role(gpui::Role::Tree)
521            .key_context(CONTEXT)
522            .track_focus(&focus_handle)
523            .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
524            .on_action(window.listener_for(&self.state, TreeState::on_action_left))
525            .on_action(window.listener_for(&self.state, TreeState::on_action_right))
526            .on_action(window.listener_for(&self.state, TreeState::on_action_up))
527            .on_action(window.listener_for(&self.state, TreeState::on_action_down))
528            .child(self.state)
529            .refine_style(&self.style)
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use gpui::{AppContext as _, Subscription};
537
538    struct EventCollector {
539        events: Rc<RefCell<Vec<TreeEvent>>>,
540        _subscription: Subscription,
541    }
542
543    impl EventCollector {
544        fn new(state: &Entity<TreeState>, cx: &mut Context<Self>) -> Self {
545            let events = Rc::new(RefCell::new(Vec::new()));
546            let captured = events.clone();
547            let subscription = cx.subscribe(state, move |_, _, event, _| {
548                captured.borrow_mut().push(event.clone());
549            });
550            Self {
551                events,
552                _subscription: subscription,
553            }
554        }
555    }
556
557    impl Render for EventCollector {
558        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
559            div()
560        }
561    }
562
563    #[test]
564    fn clones_share_state_and_ancestors_keep_nearest_first_order() {
565        let leaf = TreeItem::new("leaf", "Leaf");
566        let branch = TreeItem::new("branch", "Branch").child(leaf.clone());
567        let root = TreeItem::new("root", "Root").child(branch.clone());
568
569        leaf.clone().disabled(true).expanded(true);
570        assert!(leaf.is_disabled());
571        assert!(leaf.is_expanded());
572
573        let ancestors = root.ancestors(&"leaf".into()).unwrap();
574        assert_eq!(
575            ancestors
576                .iter()
577                .map(|item| item.id.as_str())
578                .collect::<Vec<_>>(),
579            vec!["branch", "root"]
580        );
581    }
582
583    #[gpui::test]
584    fn state_flattens_expanded_items_and_resets_selection(cx: &mut gpui::TestAppContext) {
585        let items = vec![
586            TreeItem::new("src", "src")
587                .expanded(true)
588                .child(TreeItem::new("src/lib.rs", "lib.rs")),
589            TreeItem::new("README.md", "README.md"),
590        ];
591        let state = cx.new(|cx| TreeState::new(cx).items(items));
592
593        state.update(cx, |state, cx| {
594            assert_eq!(state.entries.len(), 3);
595            assert_eq!(state.entries[1].depth(), 1);
596            state.set_selected_index(Some(1), cx);
597            state.set_items(vec![TreeItem::new("Cargo.toml", "Cargo.toml")], cx);
598            assert_eq!(state.selected_index(), None);
599            assert_eq!(state.entries.len(), 1);
600        });
601    }
602
603    #[gpui::test]
604    fn selecting_hidden_item_expands_its_ancestors(cx: &mut gpui::TestAppContext) {
605        let target = TreeItem::new("src/ui/tree.rs", "tree.rs");
606        let root =
607            TreeItem::new("src", "src").child(TreeItem::new("src/ui", "ui").child(target.clone()));
608        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
609
610        state.update(cx, |state, cx| {
611            state.set_selected_item(Some(&target), cx);
612            assert_eq!(state.entries.len(), 3);
613            assert_eq!(
614                state.selected_item().map(|item| item.id.as_str()),
615                Some("src/ui/tree.rs")
616            );
617        });
618    }
619
620    #[gpui::test]
621    fn toggling_folder_rebuilds_visible_entries(cx: &mut gpui::TestAppContext) {
622        let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
623        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
624
625        state.update(cx, |state, cx| {
626            state.toggle_expand(0, cx);
627            assert_eq!(state.entries.len(), 2);
628            state.toggle_expand(0, cx);
629            assert_eq!(state.entries.len(), 1);
630        });
631    }
632
633    #[gpui::test]
634    fn expansion_events_preserve_ids_and_set_items_stays_silent(cx: &mut gpui::TestAppContext) {
635        let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
636        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
637        let collector = cx.new(|cx| EventCollector::new(&state, cx));
638
639        state.update(cx, |state, cx| {
640            state.toggle_expand(0, cx);
641            state.toggle_expand(0, cx);
642            state.set_items(vec![TreeItem::new("README.md", "README.md")], cx);
643        });
644
645        let events = collector.read_with(cx, |collector, _| collector.events.borrow().clone());
646        assert_eq!(
647            events,
648            vec![
649                TreeEvent::Expanded("src".into()),
650                TreeEvent::Collapsed("src".into()),
651            ]
652        );
653    }
654}