Skip to main content

gpui_base/
tree.rs

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