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        self.ancestor_refs(target_id)
148            .map(|path| path.into_iter().cloned().collect())
149    }
150
151    /// Like [`Self::ancestors`], but borrows the ancestors instead of
152    /// deep-cloning each one's subtree.
153    fn ancestor_refs(&self, target_id: &SharedString) -> Option<Vec<&TreeItem>> {
154        if self.id == *target_id {
155            return Some(Vec::new());
156        }
157
158        for child in &self.children {
159            if let Some(mut path) = child.ancestor_refs(target_id) {
160                path.push(self);
161                return Some(path);
162            }
163        }
164
165        None
166    }
167}
168
169/// The interaction state supplied while rendering a visible tree entry.
170#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
171pub struct TreeEntryState {
172    selected: bool,
173    right_clicked: bool,
174}
175
176impl TreeEntryState {
177    #[inline]
178    pub fn is_selected(self) -> bool {
179        self.selected
180    }
181
182    #[inline]
183    pub fn is_right_clicked(self) -> bool {
184        self.right_clicked
185    }
186}
187
188type RenderItem = dyn Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement;
189
190/// Behavior and interaction state for a virtualized tree.
191pub struct TreeState {
192    focus_handle: FocusHandle,
193    entries: Vec<TreeEntry>,
194    scroll_handle: UniformListScrollHandle,
195    selected_ix: Option<usize>,
196    right_clicked_ix: Option<usize>,
197    render_item: Rc<RenderItem>,
198    list_style: StyleRefinement,
199}
200
201impl EventEmitter<TreeEvent> for TreeState {}
202
203impl TreeState {
204    pub fn new(cx: &mut App) -> Self {
205        Self {
206            focus_handle: cx.focus_handle(),
207            entries: Vec::new(),
208            scroll_handle: UniformListScrollHandle::default(),
209            selected_ix: None,
210            right_clicked_ix: None,
211            render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
212            list_style: StyleRefinement::default(),
213        }
214    }
215
216    pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
217        self.replace_items(items.into());
218        self
219    }
220
221    pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
222        self.replace_items(items.into());
223        self.selected_ix = None;
224        self.right_clicked_ix = None;
225        cx.notify();
226    }
227
228    pub fn selected_index(&self) -> Option<usize> {
229        self.selected_ix
230    }
231
232    pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
233        self.selected_ix = ix;
234        cx.notify();
235    }
236
237    pub fn set_selected_item(&mut self, item: Option<&TreeItem>, cx: &mut Context<Self>) {
238        if let Some(item) = item {
239            self.selected_ix = self.index_of(&item.id);
240            if self.selected_ix.is_none() {
241                self.expand_ancestors(item.id.clone(), cx);
242                self.selected_ix = self.index_of(&item.id);
243            }
244        } else {
245            self.selected_ix = None;
246        }
247        cx.notify();
248    }
249
250    pub fn selected_item(&self) -> Option<&TreeItem> {
251        self.selected_ix
252            .and_then(|ix| self.entries.get(ix).map(TreeEntry::item))
253    }
254
255    pub fn selected_entry(&self) -> Option<&TreeEntry> {
256        self.selected_ix.and_then(|ix| self.entries.get(ix))
257    }
258
259    pub fn entry(&self, ix: usize) -> Option<&TreeEntry> {
260        self.entries.get(ix)
261    }
262
263    pub fn scroll_handle(&self) -> &UniformListScrollHandle {
264        &self.scroll_handle
265    }
266
267    pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) {
268        self.scroll_handle.scroll_to_item(ix, strategy);
269    }
270
271    pub fn index_of(&self, id: &SharedString) -> Option<usize> {
272        self.entries.iter().position(|entry| &entry.item.id == id)
273    }
274
275    pub fn reveal_item(
276        &mut self,
277        id: &SharedString,
278        strategy: gpui::ScrollStrategy,
279        cx: &mut Context<Self>,
280    ) {
281        self.expand_ancestors(id.clone(), cx);
282        if let Some(ix) = self.index_of(id) {
283            self.scroll_to_item(ix, strategy);
284        }
285    }
286
287    pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
288        self.focus_handle.focus(window, cx);
289    }
290
291    fn replace_items(&mut self, items: Vec<TreeItem>) {
292        self.entries.clear();
293        for item in items {
294            self.push_root(item);
295        }
296    }
297
298    fn expand_ancestors(&mut self, target_id: SharedString, cx: &mut Context<Self>) {
299        // Entries are in depth-first order, so the root containing the target
300        // precedes every descendant entry whose subtree could also contain it.
301        let ancestors = self
302            .entries
303            .iter()
304            .filter(|entry| entry.is_root())
305            .find_map(|entry| entry.item.ancestor_refs(&target_id))
306            .unwrap_or_default();
307
308        if ancestors.is_empty() {
309            return;
310        }
311
312        for ancestor in ancestors.into_iter().rev() {
313            if !ancestor.is_expanded() {
314                ancestor.state.borrow_mut().expanded = true;
315                cx.emit(TreeEvent::Expanded(ancestor.id.clone()));
316            }
317        }
318        self.rebuild_entries();
319    }
320
321    /// Moves an owned root item into the entries, flattening its visible
322    /// descendants by reference so the root's subtree is not cloned.
323    fn push_root(&mut self, mut item: TreeItem) {
324        let children = if item.is_expanded() {
325            std::mem::take(&mut item.children)
326        } else {
327            Vec::new()
328        };
329
330        let ix = self.entries.len();
331        self.entries.push(TreeEntry::new(item, 0));
332        if !children.is_empty() {
333            for child in &children {
334                self.add_entry(child, 1);
335            }
336            self.entries[ix].item.children = children;
337        }
338    }
339
340    fn add_entry(&mut self, item: &TreeItem, depth: usize) {
341        self.entries.push(TreeEntry::new(item.clone(), depth));
342        if item.is_expanded() {
343            for child in &item.children {
344                self.add_entry(child, depth + 1);
345            }
346        }
347    }
348
349    fn toggle_expand(&mut self, ix: usize, cx: &mut Context<Self>) {
350        let Some(entry) = self.entries.get(ix) else {
351            return;
352        };
353        if !entry.is_folder() {
354            return;
355        }
356
357        let expanded = !entry.is_expanded();
358        let id = entry.item.id.clone();
359        entry.item.state.borrow_mut().expanded = expanded;
360        cx.emit(if expanded {
361            TreeEvent::Expanded(id)
362        } else {
363            TreeEvent::Collapsed(id)
364        });
365        self.right_clicked_ix = None;
366        self.rebuild_entries();
367    }
368
369    fn rebuild_entries(&mut self) {
370        let roots = std::mem::take(&mut self.entries)
371            .into_iter()
372            .filter(TreeEntry::is_root)
373            .map(|entry| entry.item)
374            .collect::<Vec<_>>();
375        self.replace_items(roots);
376    }
377
378    fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
379        if self
380            .selected_ix
381            .and_then(|ix| self.entries.get(ix).map(|entry| (ix, entry.is_folder())))
382            .is_some_and(|(ix, is_folder)| {
383                if is_folder {
384                    self.toggle_expand(ix, cx);
385                }
386                is_folder
387            })
388        {
389            cx.notify();
390        }
391    }
392
393    fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
394        if let Some(ix) = self.selected_ix
395            && self
396                .entries
397                .get(ix)
398                .is_some_and(|entry| entry.is_folder() && entry.is_expanded())
399        {
400            self.toggle_expand(ix, cx);
401            cx.notify();
402        }
403    }
404
405    fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
406        if let Some(ix) = self.selected_ix
407            && self
408                .entries
409                .get(ix)
410                .is_some_and(|entry| entry.is_folder() && !entry.is_expanded())
411        {
412            self.toggle_expand(ix, cx);
413            cx.notify();
414        }
415    }
416
417    fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
418        let mut ix = self.selected_ix.unwrap_or(0);
419        ix = ix
420            .checked_sub(1)
421            .unwrap_or_else(|| self.entries.len().saturating_sub(1));
422        self.selected_ix = Some(ix);
423        self.scroll_handle
424            .scroll_to_item(ix, gpui::ScrollStrategy::Top);
425        cx.notify();
426    }
427
428    fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
429        let mut ix = self.selected_ix.unwrap_or(0);
430        ix = if ix + 1 < self.entries.len() {
431            ix + 1
432        } else {
433            0
434        };
435        self.selected_ix = Some(ix);
436        self.scroll_handle
437            .scroll_to_item(ix, gpui::ScrollStrategy::Bottom);
438        cx.notify();
439    }
440
441    fn on_entry_click(&mut self, ix: usize, cx: &mut Context<Self>) {
442        self.selected_ix = Some(ix);
443        self.toggle_expand(ix, cx);
444        cx.notify();
445    }
446}
447
448impl Render for TreeState {
449    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
450        let render_item = self.render_item.clone();
451        uniform_list("entries", self.entries.len(), {
452            cx.processor(move |state, visible_range: Range<usize>, window, cx| {
453                visible_range
454                    .map(|ix| {
455                        let entry = &state.entries[ix];
456                        let entry_state = TreeEntryState {
457                            selected: Some(ix) == state.selected_ix,
458                            right_clicked: Some(ix) == state.right_clicked_ix,
459                        };
460                        div()
461                            .id(ix)
462                            .test_support()
463                            .role(gpui::Role::TreeItem)
464                            .aria_label(entry.item().label.clone())
465                            .aria_selected(entry_state.selected)
466                            .when(entry.is_folder(), |this| {
467                                this.aria_expanded(entry.is_expanded())
468                            })
469                            .child((render_item)(ix, entry, entry_state, window, cx))
470                            .when(!entry.is_disabled(), |this| {
471                                this.on_mouse_down(
472                                    MouseButton::Left,
473                                    cx.listener(move |state, _, _, cx| {
474                                        state.on_entry_click(ix, cx);
475                                    }),
476                                )
477                                .on_mouse_down(
478                                    MouseButton::Right,
479                                    cx.listener(move |state, _, _, cx| {
480                                        state.right_clicked_ix = Some(ix);
481                                        cx.notify();
482                                    }),
483                                )
484                            })
485                    })
486                    .collect()
487            })
488        })
489        .track_scroll(&self.scroll_handle)
490        .refine_style(&self.list_style)
491    }
492}
493
494/// An unstyled, virtualized tree element.
495#[derive(IntoElement)]
496pub struct Tree {
497    id: ElementId,
498    state: Entity<TreeState>,
499    style: StyleRefinement,
500    list_style: StyleRefinement,
501    render_item: Rc<RenderItem>,
502}
503
504impl Tree {
505    pub fn new(state: &Entity<TreeState>) -> Self {
506        Self {
507            id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
508            state: state.clone(),
509            style: StyleRefinement::default(),
510            list_style: StyleRefinement::default(),
511            render_item: Rc::new(|_, _, _, _, _| div().into_any_element()),
512        }
513    }
514
515    /// Supplies the application-owned content for each visible entry.
516    pub fn item<R>(mut self, render_item: R) -> Self
517    where
518        R: Fn(usize, &TreeEntry, TreeEntryState, &mut Window, &mut App) -> AnyElement + 'static,
519    {
520        self.render_item = Rc::new(render_item);
521        self
522    }
523
524    /// Applies caller-owned presentation to the internal virtual list.
525    pub fn list_style(mut self, style: StyleRefinement) -> Self {
526        self.list_style = style;
527        self
528    }
529}
530
531impl Styled for Tree {
532    fn style(&mut self) -> &mut StyleRefinement {
533        &mut self.style
534    }
535}
536
537impl RenderOnce for Tree {
538    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
539        let focus_handle = self.state.read(cx).focus_handle.clone();
540        self.state.update(cx, |state, _| {
541            state.render_item = self.render_item;
542            state.list_style = self.list_style;
543        });
544
545        div()
546            .id(self.id)
547            .test_support()
548            .role(gpui::Role::Tree)
549            .key_context(CONTEXT)
550            .track_focus(&focus_handle)
551            .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
552            .on_action(window.listener_for(&self.state, TreeState::on_action_left))
553            .on_action(window.listener_for(&self.state, TreeState::on_action_right))
554            .on_action(window.listener_for(&self.state, TreeState::on_action_up))
555            .on_action(window.listener_for(&self.state, TreeState::on_action_down))
556            .child(self.state)
557            .refine_style(&self.style)
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use gpui::{AppContext as _, Subscription};
565
566    struct EventCollector {
567        events: Rc<RefCell<Vec<TreeEvent>>>,
568        _subscription: Subscription,
569    }
570
571    impl EventCollector {
572        fn new(state: &Entity<TreeState>, cx: &mut Context<Self>) -> Self {
573            let events = Rc::new(RefCell::new(Vec::new()));
574            let captured = events.clone();
575            let subscription = cx.subscribe(state, move |_, _, event, _| {
576                captured.borrow_mut().push(event.clone());
577            });
578            Self {
579                events,
580                _subscription: subscription,
581            }
582        }
583    }
584
585    impl Render for EventCollector {
586        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
587            div()
588        }
589    }
590
591    #[test]
592    fn clones_share_state_and_ancestors_keep_nearest_first_order() {
593        let leaf = TreeItem::new("leaf", "Leaf");
594        let branch = TreeItem::new("branch", "Branch").child(leaf.clone());
595        let root = TreeItem::new("root", "Root").child(branch.clone());
596
597        leaf.clone().disabled(true).expanded(true);
598        assert!(leaf.is_disabled());
599        assert!(leaf.is_expanded());
600
601        let ancestors = root.ancestors(&"leaf".into()).unwrap();
602        assert_eq!(
603            ancestors
604                .iter()
605                .map(|item| item.id.as_str())
606                .collect::<Vec<_>>(),
607            vec!["branch", "root"]
608        );
609    }
610
611    #[gpui::test]
612    fn state_flattens_expanded_items_and_resets_selection(cx: &mut gpui::TestAppContext) {
613        let items = vec![
614            TreeItem::new("src", "src")
615                .expanded(true)
616                .child(TreeItem::new("src/lib.rs", "lib.rs")),
617            TreeItem::new("README.md", "README.md"),
618        ];
619        let state = cx.new(|cx| TreeState::new(cx).items(items));
620
621        state.update(cx, |state, cx| {
622            assert_eq!(state.entries.len(), 3);
623            assert_eq!(state.entries[1].depth(), 1);
624            state.set_selected_index(Some(1), cx);
625            state.set_items(vec![TreeItem::new("Cargo.toml", "Cargo.toml")], cx);
626            assert_eq!(state.selected_index(), None);
627            assert_eq!(state.entries.len(), 1);
628        });
629    }
630
631    #[gpui::test]
632    fn selecting_hidden_item_expands_its_ancestors(cx: &mut gpui::TestAppContext) {
633        let target = TreeItem::new("src/ui/tree.rs", "tree.rs");
634        let root =
635            TreeItem::new("src", "src").child(TreeItem::new("src/ui", "ui").child(target.clone()));
636        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
637
638        state.update(cx, |state, cx| {
639            state.set_selected_item(Some(&target), cx);
640            assert_eq!(state.entries.len(), 3);
641            assert_eq!(
642                state.selected_item().map(|item| item.id.as_str()),
643                Some("src/ui/tree.rs")
644            );
645        });
646    }
647
648    #[gpui::test]
649    fn revealing_item_under_later_root_keeps_other_subtrees(cx: &mut gpui::TestAppContext) {
650        let guide = TreeItem::new("docs/guide", "guide").child(TreeItem::new("docs/guide/a", "a"));
651        let docs = TreeItem::new("docs", "docs").expanded(true).child(guide);
652        let ui = TreeItem::new("src/ui", "ui").child(TreeItem::new("src/ui/tree.rs", "tree.rs"));
653        let src = TreeItem::new("src", "src").child(ui);
654        let state = cx.new(|cx| TreeState::new(cx).items(vec![docs, src]));
655
656        state.update(cx, |state, cx| {
657            assert_eq!(state.entries.len(), 3);
658            state.reveal_item(&"src/ui/tree.rs".into(), gpui::ScrollStrategy::Top, cx);
659            assert_eq!(
660                state
661                    .entries
662                    .iter()
663                    .map(|entry| (entry.item().id.as_str(), entry.depth()))
664                    .collect::<Vec<_>>(),
665                vec![
666                    ("docs", 0),
667                    ("docs/guide", 1),
668                    ("src", 0),
669                    ("src/ui", 1),
670                    ("src/ui/tree.rs", 2),
671                ]
672            );
673            assert!(state.entries[0].is_folder());
674            assert_eq!(state.entries[0].item().children.len(), 1);
675
676            state.toggle_expand(1, cx);
677            assert_eq!(state.index_of(&"docs/guide/a".into()), Some(2));
678        });
679    }
680
681    #[gpui::test]
682    fn toggling_folder_rebuilds_visible_entries(cx: &mut gpui::TestAppContext) {
683        let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
684        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
685
686        state.update(cx, |state, cx| {
687            state.toggle_expand(0, cx);
688            assert_eq!(state.entries.len(), 2);
689            state.toggle_expand(0, cx);
690            assert_eq!(state.entries.len(), 1);
691        });
692    }
693
694    #[gpui::test]
695    fn expansion_events_preserve_ids_and_set_items_stays_silent(cx: &mut gpui::TestAppContext) {
696        let root = TreeItem::new("src", "src").child(TreeItem::new("src/lib.rs", "lib.rs"));
697        let state = cx.new(|cx| TreeState::new(cx).items(vec![root]));
698        let collector = cx.new(|cx| EventCollector::new(&state, cx));
699
700        state.update(cx, |state, cx| {
701            state.toggle_expand(0, cx);
702            state.toggle_expand(0, cx);
703            state.set_items(vec![TreeItem::new("README.md", "README.md")], cx);
704        });
705
706        let events = collector.read_with(cx, |collector, _| collector.events.borrow().clone());
707        assert_eq!(
708            events,
709            vec![
710                TreeEvent::Expanded("src".into()),
711                TreeEvent::Collapsed("src".into()),
712            ]
713        );
714    }
715}