Skip to main content

rgpui_component/
tree.rs

1use std::{cell::RefCell, ops::Range, rc::Rc};
2
3use rgpui::{
4    App, Context, ElementId, Entity, EventEmitter, FocusHandle, InteractiveElement as _,
5    IntoElement, KeyBinding, ListSizingBehavior, MouseButton, ParentElement, Render, RenderOnce,
6    SharedString, StyleRefinement, Styled, UniformListScrollHandle, Window, div,
7    prelude::FluentBuilder as _, uniform_list,
8};
9
10use crate::{
11    Selectable as _, StyledExt,
12    actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp},
13    list::ListItem,
14    menu::{ContextMenuExt as _, PopupMenu},
15    scroll::ScrollableElement,
16};
17
18const CONTEXT: &str = "Tree";
19pub(crate) fn init(cx: &mut App) {
20    cx.bind_keys([
21        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
22        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
23        KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
24        KeyBinding::new("right", SelectRight, Some(CONTEXT)),
25    ]);
26}
27
28/// Create a [`Tree`].
29///
30/// # Arguments
31///
32/// * `state` - The shared state managing the tree items.
33/// * `render_item` - A closure to render each tree item.
34///
35/// ```ignore
36/// let state = cx.new(|_| {
37///     TreeState::new().items(vec![
38///         TreeItem::new("src")
39///             .child(TreeItem::new("lib.rs"),
40///         TreeItem::new("Cargo.toml"),
41///         TreeItem::new("README.md"),
42///     ])
43/// });
44///
45/// tree(&state, |ix, entry, selected, window, cx| {
46///     let item = entry.item();
47///     ListItem::new(ix).pl(px(16.) * entry.depth()).child(item.label.clone())
48/// })
49/// ```
50pub fn tree<R>(state: &Entity<TreeState>, render_item: R) -> Tree
51where
52    R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
53{
54    Tree::new(state, render_item)
55}
56
57struct TreeItemState {
58    expanded: bool,
59    disabled: bool,
60}
61
62/// A tree item with a label, children, and an expanded state.
63#[derive(Clone)]
64pub struct TreeItem {
65    pub id: SharedString,
66    pub label: SharedString,
67    pub children: Vec<TreeItem>,
68    state: Rc<RefCell<TreeItemState>>,
69}
70
71/// A flat representation of a tree item with its depth.
72#[derive(Clone)]
73pub struct TreeEntry {
74    item: TreeItem,
75    depth: usize,
76}
77
78impl TreeEntry {
79    /// Get the source tree item.
80    #[inline]
81    pub fn item(&self) -> &TreeItem {
82        &self.item
83    }
84
85    /// The depth of this item in the tree.
86    #[inline]
87    pub fn depth(&self) -> usize {
88        self.depth
89    }
90
91    #[inline]
92    fn is_root(&self) -> bool {
93        self.depth == 0
94    }
95
96    /// Whether this item is a folder (has children).
97    #[inline]
98    pub fn is_folder(&self) -> bool {
99        self.item.is_folder()
100    }
101
102    /// Return true if the item is expanded.
103    #[inline]
104    pub fn is_expanded(&self) -> bool {
105        self.item.is_expanded()
106    }
107
108    #[inline]
109    pub fn is_disabled(&self) -> bool {
110        self.item.is_disabled()
111    }
112}
113
114/// Event emitted by [`TreeState`] when user-visible state changes.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub enum TreeEvent {
117    /// A tree node was expanded.
118    Expanded(SharedString),
119    /// A tree node was collapsed.
120    Collapsed(SharedString),
121}
122
123impl TreeItem {
124    /// Create a new tree item with the given label.
125    ///
126    /// - The `id` for you to uniquely identify this item, then later you can use it for selection or other purposes.
127    /// - The `label` is the text to display for this item.
128    ///
129    /// For example, the `id` is the full file path, and the `label` is the file name.
130    ///
131    /// ```ignore
132    /// TreeItem::new("src/rgpui-component/button.rs", "button.rs")
133    /// ```
134    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
135        Self {
136            id: id.into(),
137            label: label.into(),
138            children: Vec::new(),
139            state: Rc::new(RefCell::new(TreeItemState {
140                expanded: false,
141                disabled: false,
142            })),
143        }
144    }
145
146    /// Add a child item to this tree item.
147    pub fn child(mut self, child: TreeItem) -> Self {
148        self.children.push(child);
149        self
150    }
151
152    /// Add multiple child items to this tree item.
153    pub fn children(mut self, children: impl IntoIterator<Item = TreeItem>) -> Self {
154        self.children.extend(children);
155        self
156    }
157
158    /// Set expanded state for this tree item.
159    pub fn expanded(self, expanded: bool) -> Self {
160        self.state.borrow_mut().expanded = expanded;
161        self
162    }
163
164    /// Set disabled state for this tree item.
165    pub fn disabled(self, disabled: bool) -> Self {
166        self.state.borrow_mut().disabled = disabled;
167        self
168    }
169
170    /// Whether this item is a folder (has children).
171    #[inline]
172    pub fn is_folder(&self) -> bool {
173        self.children.len() > 0
174    }
175
176    /// Return true if the item is disabled.
177    pub fn is_disabled(&self) -> bool {
178        self.state.borrow().disabled
179    }
180
181    /// Return true if the item is expanded.
182    #[inline]
183    pub fn is_expanded(&self) -> bool {
184        self.state.borrow().expanded
185    }
186
187    fn find_ancestors(&self, target_id: &SharedString) -> Option<Vec<TreeItem>> {
188        if self.id == *target_id {
189            return Some(vec![]);
190        }
191
192        for child in &self.children {
193            if let Some(mut path) = child.find_ancestors(target_id) {
194                path.push(self.clone());
195                return Some(path);
196            }
197        }
198
199        None
200    }
201}
202
203/// State for managing tree items.
204pub struct TreeState {
205    focus_handle: FocusHandle,
206    entries: Vec<TreeEntry>,
207    scroll_handle: UniformListScrollHandle,
208    selected_ix: Option<usize>,
209    right_clicked_ix: Option<usize>,
210    render_item: Rc<dyn Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem>,
211    context_menu_builder: Option<
212        Rc<dyn Fn(usize, &TreeEntry, PopupMenu, &mut Window, &mut Context<TreeState>) -> PopupMenu>,
213    >,
214}
215
216impl EventEmitter<TreeEvent> for TreeState {}
217
218impl TreeState {
219    /// Create a new empty tree state.
220    pub fn new(cx: &mut App) -> Self {
221        Self {
222            selected_ix: None,
223            right_clicked_ix: None,
224            focus_handle: cx.focus_handle(),
225            scroll_handle: UniformListScrollHandle::default(),
226            entries: Vec::new(),
227            render_item: Rc::new(|_, _, _, _, _| ListItem::new(0)),
228            context_menu_builder: None,
229        }
230    }
231
232    /// Set the tree items.
233    pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
234        let items = items.into();
235        self.entries.clear();
236        for item in items.into_iter() {
237            self.add_entry(item, 0);
238        }
239        self
240    }
241
242    /// Set the tree items.
243    pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
244        let items = items.into();
245        self.entries.clear();
246        for item in items.into_iter() {
247            self.add_entry(item, 0);
248        }
249        self.selected_ix = None;
250        self.right_clicked_ix = None;
251        cx.notify();
252    }
253
254    /// Get the currently selected index, if any.
255    pub fn selected_index(&self) -> Option<usize> {
256        self.selected_ix
257    }
258
259    /// Set the selected index, or `None` to clear selection.
260    pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
261        self.selected_ix = ix;
262        cx.notify();
263    }
264
265    /// Set the selected index by tree item, or `None` to clear selection.
266    pub fn set_selected_item(&mut self, item: Option<&TreeItem>, cx: &mut Context<Self>) {
267        if let Some(item) = item {
268            let ix = self
269                .entries
270                .iter()
271                .position(|entry| entry.item.id == item.id);
272            if ix.is_some() {
273                self.selected_ix = ix;
274            } else {
275                self.expand_ancestors(item.id.clone(), cx);
276                self.selected_ix = self
277                    .entries
278                    .iter()
279                    .position(|entry| entry.item.id == item.id);
280            }
281        } else {
282            self.selected_ix = None;
283        }
284        cx.notify();
285    }
286
287    /// Get the currently selected tree item, if any.
288    pub fn selected_item(&self) -> Option<&TreeItem> {
289        self.selected_ix
290            .and_then(|ix| self.entries.get(ix).map(|entry| &entry.item))
291    }
292
293    pub fn scroll_to_item(&mut self, ix: usize, strategy: rgpui::ScrollStrategy) {
294        self.scroll_handle.scroll_to_item(ix, strategy);
295    }
296
297    /// Get the currently selected entry, if any.
298    pub fn selected_entry(&self) -> Option<&TreeEntry> {
299        self.selected_ix.and_then(|ix| self.entries.get(ix))
300    }
301
302    fn expand_ancestors(&mut self, target_id: SharedString, cx: &mut Context<Self>) {
303        let mut ancestors = Vec::new();
304
305        for entry in &self.entries {
306            if let Some(found_ancestors) = entry.item.find_ancestors(&target_id) {
307                ancestors = found_ancestors;
308                break;
309            }
310        }
311
312        if ancestors.is_empty() {
313            return;
314        }
315
316        for ancestor in ancestors.into_iter().rev() {
317            if !ancestor.is_expanded() {
318                ancestor.state.borrow_mut().expanded = true;
319                cx.emit(TreeEvent::Expanded(ancestor.id.clone()));
320            }
321        }
322
323        self.rebuild_entries();
324    }
325
326    fn add_entry(&mut self, item: TreeItem, depth: usize) {
327        self.entries.push(TreeEntry {
328            item: item.clone(),
329            depth,
330        });
331        if item.is_expanded() {
332            for child in &item.children {
333                self.add_entry(child.clone(), depth + 1);
334            }
335        }
336    }
337
338    fn toggle_expand(&mut self, ix: usize, cx: &mut Context<Self>) {
339        let Some(entry) = self.entries.get_mut(ix) else {
340            return;
341        };
342        if !entry.is_folder() {
343            return;
344        }
345
346        let expanded = !entry.is_expanded();
347        let id = entry.item.id.clone();
348        entry.item.state.borrow_mut().expanded = expanded;
349
350        if expanded {
351            cx.emit(TreeEvent::Expanded(id));
352        } else {
353            cx.emit(TreeEvent::Collapsed(id));
354        }
355
356        self.right_clicked_ix = None;
357        self.rebuild_entries();
358    }
359
360    fn rebuild_entries(&mut self) {
361        let root_items: Vec<TreeItem> = self
362            .entries
363            .iter()
364            .filter(|e| e.is_root())
365            .map(|e| e.item.clone())
366            .collect();
367        self.entries.clear();
368        for item in root_items.into_iter() {
369            self.add_entry(item, 0);
370        }
371    }
372
373    pub fn focus(&mut self, window: &mut Window, cx: &mut App) {
374        self.focus_handle.focus(window, cx);
375    }
376
377    fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
378        if let Some(selected_ix) = self.selected_ix {
379            if let Some(entry) = self.entries.get(selected_ix) {
380                if entry.is_folder() {
381                    self.toggle_expand(selected_ix, cx);
382                    cx.notify();
383                }
384            }
385        }
386    }
387
388    fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
389        if let Some(selected_ix) = self.selected_ix {
390            if let Some(entry) = self.entries.get(selected_ix) {
391                if entry.is_folder() && entry.is_expanded() {
392                    self.toggle_expand(selected_ix, cx);
393                    cx.notify();
394                }
395            }
396        }
397    }
398
399    fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
400        if let Some(selected_ix) = self.selected_ix {
401            if let Some(entry) = self.entries.get(selected_ix) {
402                if entry.is_folder() && !entry.is_expanded() {
403                    self.toggle_expand(selected_ix, cx);
404                    cx.notify();
405                }
406            }
407        }
408    }
409
410    fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
411        let mut selected_ix = self.selected_ix.unwrap_or(0);
412
413        if selected_ix > 0 {
414            selected_ix = selected_ix - 1;
415        } else {
416            selected_ix = self.entries.len().saturating_sub(1);
417        }
418
419        self.selected_ix = Some(selected_ix);
420        self.scroll_handle
421            .scroll_to_item(selected_ix, rgpui::ScrollStrategy::Top);
422        cx.notify();
423    }
424
425    fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
426        let mut selected_ix = self.selected_ix.unwrap_or(0);
427        if selected_ix + 1 < self.entries.len() {
428            selected_ix = selected_ix + 1;
429        } else {
430            selected_ix = 0;
431        }
432
433        self.selected_ix = Some(selected_ix);
434        self.scroll_handle
435            .scroll_to_item(selected_ix, rgpui::ScrollStrategy::Bottom);
436        cx.notify();
437    }
438
439    fn on_entry_click(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) {
440        self.selected_ix = Some(ix);
441        self.toggle_expand(ix, cx);
442        cx.notify();
443    }
444}
445
446impl Render for TreeState {
447    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
448        let render_item = self.render_item.clone();
449        let state = cx.entity().clone();
450
451        div()
452            .id("tree-state")
453            .size_full()
454            .relative()
455            .context_menu({
456                let state = state.clone();
457                move |menu, window, cx: &mut Context<PopupMenu>| {
458                    if state.read(cx).context_menu_builder.is_none() {
459                        return menu;
460                    }
461
462                    let (ix, entry) = {
463                        let state = state.read(cx);
464                        let entry = state
465                            .right_clicked_ix
466                            .and_then(|ix| state.entries.get(ix).cloned());
467                        (state.right_clicked_ix, entry)
468                    };
469
470                    if let (Some(ix), Some(entry)) = (ix, entry) {
471                        state.update(cx, |state, cx| {
472                            if let Some(build) = state.context_menu_builder.clone() {
473                                build(ix, &entry, menu, window, cx)
474                            } else {
475                                menu
476                            }
477                        })
478                    } else {
479                        menu
480                    }
481                }
482            })
483            .child(
484                uniform_list("entries", self.entries.len(), {
485                    cx.processor(move |state, visible_range: Range<usize>, window, cx| {
486                        let mut items = Vec::with_capacity(visible_range.len());
487                        for ix in visible_range {
488                            let entry = &state.entries[ix];
489                            let selected = Some(ix) == state.selected_ix;
490                            let right_clicked = Some(ix) == state.right_clicked_ix;
491                            let item = (render_item)(ix, entry, selected, window, cx);
492
493                            let el = div()
494                                .id(ix)
495                                .child(
496                                    item.disabled(entry.item().is_disabled())
497                                        .selected(selected)
498                                        .secondary_selected(right_clicked),
499                                )
500                                .when(!entry.item().is_disabled(), |this| {
501                                    this.on_mouse_down(
502                                        MouseButton::Left,
503                                        cx.listener({
504                                            move |this, _, window, cx| {
505                                                this.on_entry_click(ix, window, cx);
506                                            }
507                                        }),
508                                    )
509                                    .on_mouse_down(
510                                        MouseButton::Right,
511                                        cx.listener(move |this, _, _, cx| {
512                                            this.right_clicked_ix = Some(ix);
513                                            cx.notify();
514                                        }),
515                                    )
516                                });
517
518                            items.push(el)
519                        }
520
521                        items
522                    })
523                })
524                .flex_grow()
525                .size_full()
526                .track_scroll(&self.scroll_handle)
527                .with_sizing_behavior(ListSizingBehavior::Auto)
528                .into_any_element(),
529            )
530    }
531}
532
533/// A tree view element that displays hierarchical data.
534#[derive(IntoElement)]
535pub struct Tree {
536    id: ElementId,
537    state: Entity<TreeState>,
538    style: StyleRefinement,
539    render_item: Rc<dyn Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem>,
540    context_menu_builder: Option<
541        Rc<dyn Fn(usize, &TreeEntry, PopupMenu, &mut Window, &mut Context<TreeState>) -> PopupMenu>,
542    >,
543}
544
545impl Tree {
546    pub fn new<R>(state: &Entity<TreeState>, render_item: R) -> Self
547    where
548        R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
549    {
550        Self {
551            id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
552            state: state.clone(),
553            style: StyleRefinement::default(),
554            render_item: Rc::new(move |ix, item, selected, window, app| {
555                render_item(ix, item, selected, window, app)
556            }),
557            context_menu_builder: None,
558        }
559    }
560
561    /// Add a context menu to the tree.
562    ///
563    /// The closure receives:
564    /// - `ix`: the index of the right-clicked entry
565    /// - `entry`: the right-clicked tree entry
566    /// - `menu`: the popup menu builder
567    pub fn context_menu<F>(mut self, f: F) -> Self
568    where
569        F: Fn(usize, &TreeEntry, PopupMenu, &mut Window, &mut Context<TreeState>) -> PopupMenu
570            + 'static,
571    {
572        self.context_menu_builder = Some(Rc::new(f));
573        self
574    }
575}
576
577impl Styled for Tree {
578    fn style(&mut self) -> &mut StyleRefinement {
579        &mut self.style
580    }
581}
582
583impl RenderOnce for Tree {
584    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
585        let focus_handle = self.state.read(cx).focus_handle.clone();
586        let scroll_handle = self.state.read(cx).scroll_handle.clone();
587
588        self.state.update(cx, |state, _| {
589            state.render_item = self.render_item;
590            state.context_menu_builder = self.context_menu_builder;
591        });
592
593        div()
594            .id(self.id)
595            .key_context(CONTEXT)
596            .track_focus(&focus_handle)
597            .on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
598            .on_action(window.listener_for(&self.state, TreeState::on_action_left))
599            .on_action(window.listener_for(&self.state, TreeState::on_action_right))
600            .on_action(window.listener_for(&self.state, TreeState::on_action_up))
601            .on_action(window.listener_for(&self.state, TreeState::on_action_down))
602            .size_full()
603            .child(self.state)
604            .refine_style(&self.style)
605            .vertical_scrollbar(&scroll_handle)
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use std::cell::RefCell;
612    use std::rc::Rc;
613
614    use indoc::indoc;
615
616    use super::{TreeEvent, TreeState};
617    use rgpui::{AppContext as _, Render, Subscription};
618
619    struct TestCollector {
620        _state: rgpui::Entity<TreeState>,
621        events: Rc<RefCell<Vec<TreeEvent>>>,
622        _subscription: Subscription,
623    }
624
625    impl TestCollector {
626        fn new(state: &rgpui::Entity<TreeState>, cx: &mut rgpui::Context<Self>) -> Self {
627            let events = Rc::new(RefCell::new(Vec::new()));
628            let events_clone = events.clone();
629            let _subscription = cx.subscribe(state, move |_, _, ev: &TreeEvent, _| {
630                events_clone.borrow_mut().push(ev.clone());
631            });
632            Self {
633                _state: state.clone(),
634                events,
635                _subscription,
636            }
637        }
638    }
639
640    impl Render for TestCollector {
641        fn render(
642            &mut self,
643            _: &mut rgpui::Window,
644            _: &mut rgpui::Context<Self>,
645        ) -> impl rgpui::IntoElement {
646            rgpui::div()
647        }
648    }
649
650    fn assert_entries(entries: &Vec<super::TreeEntry>, expected: &str) {
651        let actual: Vec<String> = entries
652            .iter()
653            .map(|e| {
654                let mut s = String::new();
655                s.push_str(&"    ".repeat(e.depth));
656                s.push_str(e.item().label.as_str());
657                s
658            })
659            .collect();
660        let actual = actual.join("\n");
661        assert_eq!(actual.trim(), expected.trim());
662    }
663
664    #[rgpui::test]
665    fn test_tree_entry(cx: &mut rgpui::TestAppContext) {
666        use super::TreeItem;
667
668        let items = vec![
669            TreeItem::new("src", "src")
670                .expanded(true)
671                .child(
672                    TreeItem::new("src/rgpui-component", "rgpui-component")
673                        .expanded(true)
674                        .child(TreeItem::new("src/rgpui-component/button.rs", "button.rs"))
675                        .child(TreeItem::new("src/rgpui-component/icon.rs", "icon.rs"))
676                        .child(TreeItem::new("src/rgpui-component/mod.rs", "mod.rs")),
677                )
678                .child(TreeItem::new("src/lib.rs", "lib.rs")),
679            TreeItem::new("Cargo.toml", "Cargo.toml"),
680            TreeItem::new("Cargo.lock", "Cargo.lock").disabled(true),
681            TreeItem::new("README.md", "README.md"),
682        ];
683
684        let state = cx.new(|cx| TreeState::new(cx).items(items));
685        state.update(cx, |state, cx| {
686            assert_entries(
687                &state.entries,
688                indoc! {
689                    r#"
690                src
691                    rgpui-component
692                        button.rs
693                        icon.rs
694                        mod.rs
695                    lib.rs
696                Cargo.toml
697                Cargo.lock
698                README.md
699                "#
700                },
701            );
702
703            let entry = state.entries.get(0).unwrap();
704            assert_eq!(entry.depth(), 0);
705            assert_eq!(entry.is_root(), true);
706            assert_eq!(entry.is_folder(), true);
707            assert_eq!(entry.is_expanded(), true);
708
709            let entry = state.entries.get(1).unwrap();
710            assert_eq!(entry.depth(), 1);
711            assert_eq!(entry.is_root(), false);
712            assert_eq!(entry.is_folder(), true);
713            assert_eq!(entry.is_expanded(), true);
714            assert_eq!(entry.item().label.as_str(), "rgpui-component");
715
716            state.toggle_expand(1, cx);
717            let entry = state.entries.get(1).unwrap();
718            assert_eq!(entry.is_expanded(), false);
719            assert_entries(
720                &state.entries,
721                indoc! {
722                    r#"
723                src
724                    rgpui-component
725                    lib.rs
726                Cargo.toml
727                Cargo.lock
728                README.md
729                "#
730                },
731            );
732        })
733    }
734
735    #[rgpui::test]
736    fn test_emits_expanded_event(cx: &mut rgpui::TestAppContext) {
737        let items = vec![
738            super::TreeItem::new("src", "src").child(super::TreeItem::new("src/lib.rs", "lib.rs")),
739        ];
740        let state = cx.new(|cx| TreeState::new(cx).items(items));
741        let collector = cx.new(|cx| TestCollector::new(&state, cx));
742
743        state.update(cx, |state, cx| {
744            state.toggle_expand(0, cx);
745        });
746
747        let events = collector.read_with(cx, |c, _| c.events.borrow().clone());
748        assert_eq!(events, vec![TreeEvent::Expanded("src".into())]);
749    }
750
751    #[rgpui::test]
752    fn test_emits_collapsed_event(cx: &mut rgpui::TestAppContext) {
753        let items = vec![
754            super::TreeItem::new("src", "src")
755                .expanded(true)
756                .child(super::TreeItem::new("src/lib.rs", "lib.rs")),
757        ];
758        let state = cx.new(|cx| TreeState::new(cx).items(items));
759        let collector = cx.new(|cx| TestCollector::new(&state, cx));
760
761        state.update(cx, |state, cx| {
762            state.toggle_expand(0, cx);
763        });
764
765        let events = collector.read_with(cx, |c, _| c.events.borrow().clone());
766        assert_eq!(events, vec![TreeEvent::Collapsed("src".into())]);
767    }
768
769    #[rgpui::test]
770    fn test_set_items_does_not_emit_expansion_events(cx: &mut rgpui::TestAppContext) {
771        let items = vec![
772            super::TreeItem::new("src", "src")
773                .expanded(true)
774                .child(super::TreeItem::new("src/lib.rs", "lib.rs")),
775        ];
776        let state = cx.new(|cx| TreeState::new(cx).items(items));
777        let collector = cx.new(|cx| TestCollector::new(&state, cx));
778
779        let new_items = vec![
780            super::TreeItem::new("docs", "docs")
781                .expanded(true)
782                .child(super::TreeItem::new("docs/readme.md", "readme.md")),
783        ];
784        state.update(cx, |state, cx| {
785            state.set_items(new_items, cx);
786        });
787
788        let events = collector.read_with(cx, |c, _| c.events.borrow().clone());
789        assert!(
790            events.is_empty(),
791            "set_items should not emit Expanded/Collapsed events"
792        );
793    }
794
795    #[rgpui::test]
796    fn test_event_carries_item_id(cx: &mut rgpui::TestAppContext) {
797        let items = vec![super::TreeItem::new("src", "src").expanded(true).child(
798            super::TreeItem::new("src/rgpui-component", "rgpui-component").child(
799                super::TreeItem::new("src/rgpui-component/button.rs", "button.rs"),
800            ),
801        )];
802        let state = cx.new(|cx| TreeState::new(cx).items(items));
803        let collector = cx.new(|cx| TestCollector::new(&state, cx));
804
805        // Toggle the child at index 1 ("src/rgpui-component"), event payload should be the id not the index.
806        state.update(cx, |state, cx| {
807            state.toggle_expand(1, cx);
808        });
809
810        let events = collector.read_with(cx, |c, _| c.events.borrow().clone());
811        assert_eq!(
812            events,
813            vec![TreeEvent::Expanded("src/rgpui-component".into())]
814        );
815    }
816
817    #[rgpui::test]
818    fn test_set_selected_item_emits_expanded_events_for_hidden_ancestors(
819        cx: &mut rgpui::TestAppContext,
820    ) {
821        let target = super::TreeItem::new("src/rgpui-component/button.rs", "button.rs");
822        let items =
823            vec![
824                super::TreeItem::new("src", "src").child(
825                    super::TreeItem::new("src/rgpui-component", "rgpui-component")
826                        .child(target.clone()),
827                ),
828            ];
829        let state = cx.new(|cx| TreeState::new(cx).items(items));
830        let collector = cx.new(|cx| TestCollector::new(&state, cx));
831
832        state.update(cx, |state, cx| {
833            state.set_selected_item(Some(&target), cx);
834        });
835
836        let events = collector.read_with(cx, |c, _| c.events.borrow().clone());
837        assert_eq!(
838            events,
839            vec![
840                TreeEvent::Expanded("src".into()),
841                TreeEvent::Expanded("src/rgpui-component".into())
842            ]
843        );
844    }
845}