Skip to main content

herogpui_components/
tree_view.rs

1//! `TreeView` and `TreeItem` — a hierarchical list with expandable rows
2//! (HeroGPUI extension; HeroUI v3 has no tree, only `Table`'s tree rows).
3//!
4//! The tree is one tab stop with a roving cursor, like `ListBox`, and uses
5//! the same list navigation (`list_nav`) and selection rules (`selection`):
6//!
7//! - **Navigation.** Up and Down move over the visible rows, skipping
8//!   disabled ones; Home and End jump to the first and last. Right on a
9//!   collapsed parent expands it and on an expanded one moves to its first
10//!   child; Left on an expanded parent collapses it and otherwise moves to the
11//!   row's parent. Typing a row's first letters moves to it.
12//! - **Expansion.** Pressing a row's disclosure chevron toggles it. With
13//!   `selection_mode` `None`, pressing the row itself, Enter or Space toggles
14//!   it too.
15//! - **Selection.** `SelectionMode::Single` or `Multiple` (the `ListBox`
16//!   model: a press, Enter or Space toggles the row; Single replaces).
17//!   In `Multiple`, Shift+Up/Down and a Shift press extend the selection
18//!   from the anchor (the last row a press or key selected) over the visible
19//!   rows between, skipping disabled ones, as React Stately's
20//!   `extendSelection` does; Shift+Home/End move the cursor only, as they do
21//!   in `ListBox` without Control. Escape clears a non-empty selection unless
22//!   empty selection is disallowed. Disabled rows cannot be focused, selected
23//!   or expanded.
24//! - **Virtualisation.** Rows render through a uniform
25//!   [`VirtualList`](crate::VirtualList), so only the rows in view are built:
26//!   with [`max_h`](TreeView::max_h), or in a parent that bounds its height,
27//!   a tree of thousands of open rows scrolls and builds a screenful. The
28//!   keyboard cursor scrolls into view.
29//! - **Accessibility.** `Role::Tree` on the root and `Role::TreeItem` on each
30//!   row, named by its label, with its level, its position among its
31//!   siblings (`aria-posinset` / `aria-setsize`, as React Aria's
32//!   `useGridListItem` counts them), `expanded` on parents and `selected`
33//!   when the tree selects.
34//!
35//! Expanded and selected keys are each controlled (`expanded_keys`,
36//! `selected_keys`) or uncontrolled (`default_expanded_keys`,
37//! `default_selected_keys`), and changes are reported either way.
38//!
39//! ```
40//! use herogpui_components::{SelectionMode, TreeItem, TreeView};
41//!
42//! let tree = TreeView::new(
43//!     "files",
44//!     vec![
45//!         TreeItem::new("src", "src").children(vec![
46//!             TreeItem::new("lib", "lib.rs"),
47//!             TreeItem::new("main", "main.rs"),
48//!         ]),
49//!         TreeItem::new("readme", "README.md"),
50//!     ],
51//! )
52//! .selection_mode(SelectionMode::Single)
53//! .default_expanded_keys(["src".into()])
54//! .on_selection_change(|keys, _window, _cx| println!("{keys:?}"));
55//! ```
56
57use std::collections::HashSet;
58use std::sync::Arc;
59
60use gpui::{
61    div, prelude::FluentBuilder as _, px, App, ElementId, InteractiveElement, IntoElement,
62    MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
63    Window,
64};
65use herogpui_core::{element_id, SelectionMode};
66use herogpui_theme::ActiveTheme;
67
68use crate::a11y::{self, A11y as _};
69use crate::list_box::{extend_selection_range, ListBoxSelectionRange as SelectionRange};
70use crate::{icons, util};
71
72type KeysCallback = Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>;
73
74/// The height of one tree row.
75const ROW_HEIGHT: gpui::Pixels = px(32.);
76/// The space between two rows. Each virtual row slot is a row plus this gap.
77const ROW_GAP: gpui::Pixels = px(2.);
78/// The tree's inner padding.
79const PADDING: gpui::Pixels = px(4.);
80/// The indent one level of depth adds.
81const INDENT: f32 = 16.;
82
83/// One node of a [`TreeView`]: a row, and the rows nested under it.
84#[must_use = "builder methods return a new value; pass it on to its component"]
85pub struct TreeItem {
86    key: SharedString,
87    label: SharedString,
88    icon: Option<SharedString>,
89    children: Vec<TreeItem>,
90    is_disabled: bool,
91}
92
93impl TreeItem {
94    /// A row `label` identified by `key`, which must be unique in the tree.
95    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
96        Self {
97            key: key.into(),
98            label: label.into(),
99            icon: None,
100            children: Vec::new(),
101            is_disabled: false,
102        }
103    }
104
105    /// Appends a child row.
106    pub fn child(mut self, item: TreeItem) -> Self {
107        self.children.push(item);
108        self
109    }
110
111    /// Appends child rows. A row with children is a parent: it draws a
112    /// disclosure chevron and can expand.
113    pub fn children(mut self, items: impl IntoIterator<Item = TreeItem>) -> Self {
114        self.children.extend(items);
115        self
116    }
117
118    /// An SVG path drawn before the label.
119    pub fn icon(mut self, path: impl Into<SharedString>) -> Self {
120        self.icon = Some(path.into());
121        self
122    }
123
124    /// A disabled row renders dimmed and cannot be focused, selected or
125    /// expanded. Its children are not disabled by it.
126    pub fn is_disabled(mut self, disabled: bool) -> Self {
127        self.is_disabled = disabled;
128        self
129    }
130}
131
132/// A tree of expandable rows. See the [module docs](self).
133#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
134#[derive(IntoElement)]
135pub struct TreeView {
136    id: ElementId,
137    items: Vec<TreeItem>,
138    selection_mode: SelectionMode,
139    selected_keys: HashSet<SharedString>,
140    is_selection_controlled: bool,
141    default_selected_keys: HashSet<SharedString>,
142    disallow_empty_selection: bool,
143    expanded_keys: HashSet<SharedString>,
144    is_expansion_controlled: bool,
145    default_expanded_keys: HashSet<SharedString>,
146    disabled_keys: HashSet<SharedString>,
147    is_disabled: bool,
148    on_selection_change: Option<KeysCallback>,
149    on_expanded_change: Option<KeysCallback>,
150    max_h: Option<gpui::Pixels>,
151}
152
153impl TreeView {
154    /// A tree over `items`. `id` keys its cursor, expansion and selection;
155    /// give every instance its own.
156    pub fn new(id: impl Into<ElementId>, items: Vec<TreeItem>) -> Self {
157        Self {
158            id: id.into(),
159            items,
160            // `ListBox`'s default, and React Stately's: a plain tree does not
161            // select.
162            selection_mode: SelectionMode::None,
163            selected_keys: HashSet::new(),
164            is_selection_controlled: false,
165            default_selected_keys: HashSet::new(),
166            disallow_empty_selection: false,
167            expanded_keys: HashSet::new(),
168            is_expansion_controlled: false,
169            default_expanded_keys: HashSet::new(),
170            disabled_keys: HashSet::new(),
171            is_disabled: false,
172            on_selection_change: None,
173            on_expanded_change: None,
174            max_h: None,
175        }
176    }
177
178    /// Caps the tree's height. Taller content scrolls inside it, and only
179    /// the rows in view are built. Without a cap the tree is as tall as its
180    /// open rows unless its parent bounds it.
181    pub fn max_h(mut self, height: impl Into<gpui::Pixels>) -> Self {
182        self.max_h = Some(height.into());
183        self
184    }
185
186    /// `None` (the default), `Single` or `Multiple`.
187    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
188        self.selection_mode = mode;
189        self
190    }
191
192    /// The controlled selection.
193    pub fn selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
194        self.selected_keys = keys.into_iter().collect();
195        self.is_selection_controlled = true;
196        self
197    }
198
199    /// The initial selection of an uncontrolled tree.
200    pub fn default_selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
201        self.default_selected_keys = keys.into_iter().collect();
202        self
203    }
204
205    /// Keeps the last selected row from being deselected.
206    pub fn disallow_empty_selection(mut self, v: bool) -> Self {
207        self.disallow_empty_selection = v;
208        self
209    }
210
211    /// The controlled set of expanded parents.
212    pub fn expanded_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
213        self.expanded_keys = keys.into_iter().collect();
214        self.is_expansion_controlled = true;
215        self
216    }
217
218    /// The parents expanded at first in an uncontrolled tree.
219    pub fn default_expanded_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
220        self.default_expanded_keys = keys.into_iter().collect();
221        self
222    }
223
224    /// Rows that behave as [`TreeItem::is_disabled`].
225    pub fn disabled_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
226        self.disabled_keys = keys.into_iter().collect();
227        self
228    }
229
230    /// A disabled tree renders dimmed, leaves the tab order and ignores
231    /// input.
232    pub fn is_disabled(mut self, disabled: bool) -> Self {
233        self.is_disabled = disabled;
234        self
235    }
236
237    /// Reports the selection after a press, Enter, Space or Escape changed it.
238    pub fn on_selection_change(
239        mut self,
240        f: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
241    ) -> Self {
242        self.on_selection_change = Some(Arc::new(f));
243        self
244    }
245
246    /// Reports the expanded set after a chevron, a key or a press changed it.
247    pub fn on_expanded_change(
248        mut self,
249        f: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
250    ) -> Self {
251        self.on_expanded_change = Some(Arc::new(f));
252        self
253    }
254}
255
256/// One visible row after flattening.
257#[derive(Clone)]
258struct Row {
259    key: SharedString,
260    label: SharedString,
261    icon: Option<SharedString>,
262    depth: usize,
263    has_children: bool,
264    expanded: bool,
265    /// The parent's index in the flattened rows.
266    parent: Option<usize>,
267    disabled: bool,
268    /// The zero-based position among the row's siblings.
269    pos_in_set: usize,
270    /// How many siblings the row has, itself included.
271    set_size: usize,
272}
273
274/// Depth-first, through open parents only: a nested row does not exist
275/// until its parent is expanded (the same walk as `Table`'s tree rows).
276fn flatten(
277    items: Vec<TreeItem>,
278    depth: usize,
279    parent: Option<usize>,
280    expanded: &HashSet<SharedString>,
281    disabled_keys: &HashSet<SharedString>,
282    out: &mut Vec<Row>,
283) {
284    let set_size = items.len();
285    for (pos_in_set, item) in items.into_iter().enumerate() {
286        let has_children = !item.children.is_empty();
287        let is_open = has_children && expanded.contains(&item.key);
288        let index = out.len();
289        out.push(Row {
290            disabled: item.is_disabled || disabled_keys.contains(&item.key),
291            key: item.key,
292            label: item.label,
293            icon: item.icon,
294            depth,
295            has_children,
296            expanded: is_open,
297            parent,
298            pos_in_set,
299            set_size,
300        });
301        if is_open {
302            flatten(
303                item.children,
304                depth + 1,
305                Some(index),
306                expanded,
307                disabled_keys,
308                out,
309            );
310        }
311    }
312}
313
314/// The keys of `key`'s ancestors, nearest first, or `None` when `key` is
315/// not in the tree.
316fn ancestors_of(items: &[TreeItem], key: &SharedString) -> Option<Vec<SharedString>> {
317    for item in items {
318        if &item.key == key {
319            return Some(Vec::new());
320        }
321        if let Some(mut path) = ancestors_of(&item.children, key) {
322            path.push(item.key.clone());
323            return Some(path);
324        }
325    }
326    None
327}
328
329/// The selection after activating `key`: `ListBox`'s rule.
330fn toggled_selection(
331    current: &HashSet<SharedString>,
332    key: &SharedString,
333    mode: SelectionMode,
334    disallow_empty: bool,
335) -> HashSet<SharedString> {
336    let order: Vec<SharedString> = current.iter().cloned().collect();
337    crate::selection::next_selection(&order, key, mode, disallow_empty)
338        .into_iter()
339        .collect()
340}
341
342/// What the event closures share, cloned into each.
343#[derive(Clone)]
344struct TreeState {
345    cursor: gpui::Entity<Option<SharedString>>,
346    selected: HashSet<SharedString>,
347    selected_own: Option<gpui::Entity<HashSet<SharedString>>>,
348    expanded: HashSet<SharedString>,
349    expanded_own: Option<gpui::Entity<HashSet<SharedString>>>,
350    mode: SelectionMode,
351    disallow_empty: bool,
352    on_selection_change: Option<KeysCallback>,
353    on_expanded_change: Option<KeysCallback>,
354    /// The Shift range's anchor and moving end.
355    range: gpui::Entity<SelectionRange>,
356    /// The visible rows' keys, in order: the collection a range spans.
357    order: std::rc::Rc<Vec<SharedString>>,
358    /// The keys a range may select: the visible rows that are not disabled.
359    selectable: std::rc::Rc<HashSet<SharedString>>,
360}
361
362impl TreeState {
363    fn set_cursor(&self, key: &SharedString, cx: &mut App) {
364        self.cursor.update(cx, |value, cx| {
365            if value.as_ref() != Some(key) {
366                *value = Some(key.clone());
367                cx.notify();
368            }
369        });
370    }
371
372    fn set_expanded(&self, key: &SharedString, open: bool, window: &mut Window, cx: &mut App) {
373        if self.expanded.contains(key) == open {
374            return;
375        }
376        let mut next = self.expanded.clone();
377        if open {
378            next.insert(key.clone());
379        } else {
380            next.remove(key);
381        }
382        if let Some(own) = &self.expanded_own {
383            own.update(cx, |value, cx| {
384                value.clone_from(&next);
385                cx.notify();
386            });
387        }
388        if let Some(cb) = &self.on_expanded_change {
389            cb(&next, window, cx);
390        }
391    }
392
393    fn set_selected(&self, next: HashSet<SharedString>, window: &mut Window, cx: &mut App) {
394        if next == self.selected {
395            return;
396        }
397        if let Some(own) = &self.selected_own {
398            own.update(cx, |value, cx| {
399                value.clone_from(&next);
400                cx.notify();
401            });
402        }
403        if let Some(cb) = &self.on_selection_change {
404            cb(&next, window, cx);
405        }
406    }
407
408    /// A Shift press or Shift+Up/Down onto `key` in multiple mode: select
409    /// the selectable rows from the anchor to `key`, replacing the previous
410    /// range, and move the range's end to `key`.
411    fn extend_to(&self, key: &SharedString, window: &mut Window, cx: &mut App) {
412        let range = self.range.read(cx).clone();
413        let next =
414            extend_selection_range(&self.selected, &self.order, &self.selectable, &range, key);
415        self.range.update(cx, |range, _| {
416            if range.anchor.is_none() {
417                range.anchor = Some(key.clone());
418            }
419            range.current = Some(key.clone());
420            range.is_all = false;
421        });
422        self.set_selected(next, window, cx);
423    }
424
425    /// Whether a Shift navigation or press extends the selection.
426    fn extends(&self, shift: bool) -> bool {
427        shift && self.mode == SelectionMode::Multiple
428    }
429
430    /// A press, Enter or Space on `row`: select it, or with no selection
431    /// mode toggle a parent. Selecting a row in multiple mode seats the
432    /// Shift range's anchor on it, as `ListBox` does.
433    fn activate(&self, row: &Row, window: &mut Window, cx: &mut App) {
434        if row.disabled {
435            return;
436        }
437        if crate::selection::reports_changes(self.mode) {
438            let was_selected = self.selected.contains(&row.key);
439            let next = toggled_selection(&self.selected, &row.key, self.mode, self.disallow_empty);
440            if self.mode == SelectionMode::Multiple && !was_selected {
441                self.range.update(cx, |range, _| {
442                    *range = SelectionRange {
443                        anchor: Some(row.key.clone()),
444                        current: Some(row.key.clone()),
445                        is_all: false,
446                    };
447                });
448            }
449            self.set_selected(next, window, cx);
450        } else if row.has_children {
451            self.set_expanded(&row.key, !row.expanded, window, cx);
452        }
453    }
454}
455
456/// The debug-selector spelling of an id: its name when it has one.
457fn selector_base(id: &ElementId) -> String {
458    match id {
459        ElementId::Name(name) => name.to_string(),
460        other => format!("{other:?}"),
461    }
462}
463
464impl RenderOnce for TreeView {
465    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
466        let base = self.id.clone();
467        let selector = selector_base(&base);
468        let focus_handle = window
469            .use_keyed_state(element_id::scoped(&base, "focus"), cx, |_, cx| {
470                cx.focus_handle()
471            })
472            .read(cx)
473            .clone()
474            .tab_stop(!self.is_disabled);
475        let cursor = window.use_keyed_state(element_id::scoped(&base, "cursor"), cx, |_, _| {
476            None::<SharedString>
477        });
478        let typed = window.use_keyed_state(element_id::scoped(&base, "typed"), cx, |_, _| {
479            crate::list_nav::Typeahead::default()
480        });
481        let range = window.use_keyed_state(element_id::scoped(&base, "range"), cx, |_, _| {
482            SelectionRange::default()
483        });
484        let list = window
485            .use_keyed_state(element_id::scoped(&base, "list"), cx, |_, _| {
486                crate::VirtualListHandle::uniform(0)
487            })
488            .read(cx)
489            .clone();
490        let (selected, selected_own) = util::controlled(
491            window,
492            cx,
493            element_id::scoped(&base, "selected"),
494            self.is_selection_controlled
495                .then(|| self.selected_keys.clone()),
496            self.default_selected_keys.clone(),
497        );
498        let (expanded, expanded_own) = util::controlled(
499            window,
500            cx,
501            element_id::scoped(&base, "expanded"),
502            self.is_expansion_controlled
503                .then(|| self.expanded_keys.clone()),
504            self.default_expanded_keys.clone(),
505        );
506
507        // A cursor row hidden under a collapsed ancestor (a controlled
508        // `expanded_keys` can collapse one without a key reaching the tree)
509        // moves to its nearest visible ancestor, where a Left collapse would
510        // have left it.
511        let cursor_ancestors = cursor
512            .read(cx)
513            .as_ref()
514            .and_then(|key| ancestors_of(&self.items, key))
515            .unwrap_or_default();
516        let mut rows = Vec::new();
517        flatten(
518            self.items,
519            0,
520            None,
521            &expanded,
522            &self.disabled_keys,
523            &mut rows,
524        );
525        let disabled_tree = self.is_disabled;
526        let stops: Vec<usize> = rows
527            .iter()
528            .enumerate()
529            .filter(|(_, row)| !row.disabled && !disabled_tree)
530            .map(|(ix, _)| ix)
531            .collect();
532
533        // The cursor is a key, so it survives rows opening and closing above
534        // it. One hidden by a collapsed ancestor moves to the nearest visible
535        // ancestor; one that is otherwise no longer a visible stop falls
536        // back, on focus, to the first selected row and then the first row,
537        // as React Aria enters a collection.
538        let has_focus = focus_handle.is_focused(window);
539        let held = cursor
540            .read(cx)
541            .as_ref()
542            .and_then(|key| rows.iter().position(|row| &row.key == key))
543            .filter(|ix| stops.contains(ix))
544            .or_else(|| {
545                let ancestor = cursor_ancestors.iter().find_map(|key| {
546                    rows.iter()
547                        .position(|row| &row.key == key)
548                        .filter(|ix| stops.contains(ix))
549                })?;
550                let key = rows[ancestor].key.clone();
551                cursor.update(cx, |value, _| *value = Some(key));
552                Some(ancestor)
553            });
554        let cursor_at = held.or_else(|| {
555            has_focus
556                .then(|| {
557                    stops
558                        .iter()
559                        .copied()
560                        .find(|ix| selected.contains(&rows[*ix].key))
561                        .or_else(|| stops.first().copied())
562                })
563                .flatten()
564        });
565        let focused_at = (has_focus && window.is_window_active())
566            .then_some(cursor_at)
567            .flatten();
568
569        let state = TreeState {
570            cursor,
571            selected: selected.clone(),
572            selected_own,
573            expanded,
574            expanded_own,
575            mode: self.selection_mode,
576            disallow_empty: self.disallow_empty_selection,
577            on_selection_change: self.on_selection_change.clone(),
578            on_expanded_change: self.on_expanded_change.clone(),
579            range,
580            order: std::rc::Rc::new(rows.iter().map(|row| row.key.clone()).collect()),
581            selectable: std::rc::Rc::new(stops.iter().map(|ix| rows[*ix].key.clone()).collect()),
582        };
583        let rows = std::rc::Rc::new(rows);
584        if list.item_count() != rows.len() {
585            list.splice(0..list.item_count(), rows.len());
586        }
587
588        let colors = cx.colors().clone();
589        let radius = util::soft_radius(cx);
590        let pointer = util::interactive_cursor(cx);
591        let disabled_opacity = cx.layout().disabled_opacity;
592        let selects = self.selection_mode != SelectionMode::None;
593
594        let mut tree = div()
595            .id(base.clone())
596            .a11y(a11y::Role::Tree)
597            .relative()
598            .flex()
599            .flex_col()
600            .text_size(util::FIELD_TEXT)
601            .text_color(colors.foreground)
602            .debug_selector({
603                let name = format!("{selector}-tree");
604                move || name
605            });
606
607        if disabled_tree {
608            tree = tree.opacity(disabled_opacity);
609        } else {
610            let keys_state = state.clone();
611            let keys_rows = rows.clone();
612            let keys_focus = focus_handle.clone();
613            let keys_list = list.clone();
614            let keys_stops = stops;
615            tree = tree
616                .track_focus(&focus_handle)
617                .key_context("TreeView")
618                .on_mouse_down(MouseButton::Left, {
619                    let focus = focus_handle.clone();
620                    move |_, window, cx| window.focus(&focus, cx)
621                })
622                .on_key_down(move |event, window, cx| {
623                    if !keys_focus.is_focused(window) {
624                        return;
625                    }
626                    let m = &event.keystroke.modifiers;
627                    if m.control || m.alt || m.platform || m.function {
628                        return;
629                    }
630                    let key = event.keystroke.key.as_str();
631                    let rows = &keys_rows;
632                    let state = &keys_state;
633                    let from = cursor_at;
634                    let moved = |to: usize, cx: &mut App| {
635                        state.set_cursor(&rows[to].key, cx);
636                        keys_list.scroll_to_item(to, crate::VirtualListScroll::Reveal);
637                        util::set_focus_visible(true, cx);
638                    };
639                    match key {
640                        "right" => {
641                            let Some(ix) = from else { return };
642                            let row = &rows[ix];
643                            if row.has_children && !row.expanded {
644                                state.set_expanded(&row.key, true, window, cx);
645                                util::set_focus_visible(true, cx);
646                            } else if row.has_children {
647                                let child = ix + 1;
648                                if rows.get(child).is_some_and(|c| c.parent == Some(ix))
649                                    && keys_stops.contains(&child)
650                                {
651                                    moved(child, cx);
652                                }
653                            }
654                            cx.stop_propagation();
655                        }
656                        "left" => {
657                            let Some(ix) = from else { return };
658                            let row = &rows[ix];
659                            if row.has_children && row.expanded {
660                                state.set_expanded(&row.key, false, window, cx);
661                                util::set_focus_visible(true, cx);
662                            } else if let Some(parent) =
663                                row.parent.filter(|p| keys_stops.contains(p))
664                            {
665                                moved(parent, cx);
666                            }
667                            cx.stop_propagation();
668                        }
669                        "escape"
670                            if !m.shift
671                                && crate::selection::reports_changes(state.mode)
672                                && !state.disallow_empty
673                                && !state.selected.is_empty() =>
674                        {
675                            state
676                                .range
677                                .update(cx, |range, _| *range = SelectionRange::default());
678                            state.set_selected(HashSet::new(), window, cx);
679                            cx.stop_propagation();
680                        }
681                        _ => match crate::list_nav::resolve(&keys_stops, from, key, false) {
682                            crate::list_nav::Move::To(to) => {
683                                moved(to, cx);
684                                // Shift+Up/Down extend a multiple selection;
685                                // Shift+Home/End only move, as in `ListBox`
686                                // without Control.
687                                if state.extends(m.shift)
688                                    && matches!(key, "up" | "down")
689                                    && Some(to) != from
690                                {
691                                    state.extend_to(&rows[to].key, window, cx);
692                                }
693                                cx.stop_propagation();
694                            }
695                            crate::list_nav::Move::Activate => {
696                                if let Some(ix) = from {
697                                    util::set_focus_visible(true, cx);
698                                    state.activate(&rows[ix], window, cx);
699                                }
700                                cx.stop_propagation();
701                            }
702                            crate::list_nav::Move::Ignore => {
703                                if m.shift || !crate::list_nav::is_typeahead_key(key) {
704                                    return;
705                                }
706                                let labels: Vec<String> =
707                                    rows.iter().map(|row| row.label.to_string()).collect();
708                                let now = web_time::Instant::now();
709                                let (query, repeat) = typed.update(cx, |t, _| {
710                                    let query = t.push(key, now);
711                                    (query, t.is_repeat())
712                                });
713                                if let Some(found) = crate::list_nav::typeahead(
714                                    &labels,
715                                    &keys_stops,
716                                    from,
717                                    &query,
718                                    repeat,
719                                ) {
720                                    moved(found, cx);
721                                }
722                            }
723                        },
724                    }
725                });
726        }
727
728        // Only the rows in view are built. Each slot is a row plus the gap
729        // below it, so a slot's top is where a flex column with that gap
730        // would put the row; the bottom padding is one gap short to match.
731        let content_height = PADDING * 2. + (ROW_HEIGHT + ROW_GAP) * rows.len() as f32 - ROW_GAP;
732        let rows_id = element_id::scoped(&base, "rows");
733        let render_row = {
734            move |ix: usize, _window: &mut Window, cx: &mut App| {
735                let row = &rows[ix];
736                let disabled = row.disabled || disabled_tree;
737                let is_selected = selects && selected.contains(&row.key);
738                let is_cursor = cursor_at == Some(ix);
739                let fg = if is_selected {
740                    colors.accent.soft_foreground(colors.foreground)
741                } else {
742                    colors.foreground
743                };
744                let hover_bg = colors.default.color;
745                let mut el = div()
746                    .id(element_id::scoped(
747                        &element_id::scoped(&base, "item"),
748                        row.key.clone(),
749                    ))
750                    .a11y_named(
751                        a11y::Role::TreeItem,
752                        &a11y::Name::labelled(row.label.clone()),
753                    )
754                    .a11y_level(row.depth)
755                    .a11y_set_position(row.pos_in_set, row.set_size)
756                    .when(row.has_children, |el| el.a11y_expanded(row.expanded))
757                    .when(selects, |el| el.a11y_selected(is_selected))
758                    // One focus handle on the tree and a cursor through its
759                    // rows: gpui states that relation on the row, not the
760                    // container.
761                    .when(is_cursor, |el| el.a11y_active_descendant())
762                    .relative()
763                    .flex()
764                    .flex_row()
765                    .items_center()
766                    .gap(px(4.))
767                    .h(ROW_HEIGHT)
768                    .flex_shrink_0()
769                    .pl(px(4. + INDENT * row.depth as f32))
770                    .pr(px(8.))
771                    .rounded(radius)
772                    .text_color(fg)
773                    .when(is_selected, |el| el.bg(colors.accent.soft()))
774                    .debug_selector({
775                        let name = format!("{selector}-row-{}", row.key);
776                        move || name
777                    });
778                if disabled {
779                    el = el.opacity(disabled_opacity);
780                } else {
781                    el = el
782                        .cursor(pointer)
783                        .when(!is_selected, |el| el.hover(move |s| s.bg(hover_bg)));
784                }
785                el = util::with_focus_ring_overlay(
786                    el,
787                    util::shows_focus_ring(focused_at == Some(ix), cx),
788                    true,
789                    radius,
790                    Vec::new(),
791                    cx,
792                );
793
794                // The disclosure slot: a chevron on a parent, an empty slot on
795                // a leaf so siblings line up.
796                let mut chevron = div()
797                    .id(element_id::scoped(
798                        &element_id::scoped(&base, "toggle"),
799                        row.key.clone(),
800                    ))
801                    .flex()
802                    .items_center()
803                    .justify_center()
804                    .size(px(16.))
805                    .flex_shrink_0();
806                if row.has_children {
807                    chevron = chevron
808                        .debug_selector({
809                            let name = format!("{selector}-toggle-{}", row.key);
810                            move || name
811                        })
812                        .child(
813                            gpui::svg()
814                                .size(px(12.))
815                                .path(if row.expanded {
816                                    icons::CHEVRON_DOWN
817                                } else {
818                                    icons::CHEVRON_RIGHT
819                                })
820                                .text_color(colors.muted),
821                        );
822                    if !disabled {
823                        let toggle = state.clone();
824                        let focus = focus_handle.clone();
825                        let key = row.key.clone();
826                        let open = row.expanded;
827                        chevron = chevron
828                            .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
829                            .on_click(move |_, window, cx| {
830                                cx.stop_propagation();
831                                util::set_focus_visible(false, cx);
832                                window.focus(&focus, cx);
833                                toggle.set_cursor(&key, cx);
834                                toggle.set_expanded(&key, !open, window, cx);
835                            });
836                    }
837                }
838                el = el.child(chevron);
839                if let Some(path) = &row.icon {
840                    el = el.child(
841                        gpui::svg()
842                            .size(util::FIELD_ICON)
843                            .path(path.clone())
844                            .flex_shrink_0()
845                            .text_color(fg),
846                    );
847                }
848                el = el.child(div().flex_1().min_w_0().truncate().child(row.label.clone()));
849
850                if !disabled {
851                    let press = state.clone();
852                    let press_rows = rows.clone();
853                    el = el.on_click(move |event, window, cx| {
854                        let row = &press_rows[ix];
855                        util::set_focus_visible(false, cx);
856                        press.set_cursor(&row.key, cx);
857                        if press.extends(event.modifiers().shift) {
858                            press.extend_to(&row.key, window, cx);
859                        } else {
860                            press.activate(row, window, cx);
861                        }
862                    });
863                }
864                if cursor_at == Some(ix) && focus_handle.is_focused(_window) {
865                    el = util::record_focus_bounds(el, &focus_handle, _window, cx);
866                }
867                div()
868                    .w_full()
869                    .h(ROW_HEIGHT + ROW_GAP)
870                    .child(el)
871                    .into_any_element()
872            }
873        };
874        let rows_list = crate::VirtualList::new(rows_id, &list, render_row).padding(
875            PADDING,
876            PADDING,
877            PADDING - ROW_GAP,
878        );
879        let rows_list = match self.max_h {
880            Some(max_h) => rows_list.height(max_h.min(content_height)),
881            None => rows_list,
882        };
883        tree.child(rows_list)
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    fn tree() -> Vec<TreeItem> {
892        vec![
893            TreeItem::new("a", "A").children(vec![
894                TreeItem::new("a1", "A1"),
895                TreeItem::new("a2", "A2").child(TreeItem::new("a2x", "A2x")),
896            ]),
897            TreeItem::new("b", "B").is_disabled(true),
898        ]
899    }
900
901    fn keys(rows: &[Row]) -> Vec<&str> {
902        rows.iter().map(|row| row.key.as_ref()).collect()
903    }
904
905    #[test]
906    fn flatten_walks_open_parents_only() {
907        let mut rows = Vec::new();
908        flatten(tree(), 0, None, &HashSet::new(), &HashSet::new(), &mut rows);
909        assert_eq!(keys(&rows), ["a", "b"]);
910
911        let open: HashSet<SharedString> = ["a".into(), "a2".into()].into_iter().collect();
912        let mut rows = Vec::new();
913        flatten(tree(), 0, None, &open, &HashSet::new(), &mut rows);
914        assert_eq!(keys(&rows), ["a", "a1", "a2", "a2x", "b"]);
915        assert_eq!(
916            rows.iter().map(|r| r.depth).collect::<Vec<_>>(),
917            [0, 1, 1, 2, 0]
918        );
919        assert_eq!(rows[3].parent, Some(2));
920        assert!(rows[4].disabled);
921        assert!(rows[0].expanded && !rows[1].has_children);
922    }
923
924    #[test]
925    fn rows_count_their_position_among_siblings() {
926        let open: HashSet<SharedString> = ["a".into(), "a2".into()].into_iter().collect();
927        let mut rows = Vec::new();
928        flatten(tree(), 0, None, &open, &HashSet::new(), &mut rows);
929        // a (1 of 2), a1 (1 of 2), a2 (2 of 2), a2x (1 of 1), b (2 of 2).
930        assert_eq!(
931            rows.iter()
932                .map(|r| (r.pos_in_set, r.set_size))
933                .collect::<Vec<_>>(),
934            [(0, 2), (0, 2), (1, 2), (0, 1), (1, 2)]
935        );
936    }
937
938    #[test]
939    fn an_open_key_on_a_leaf_is_not_expanded() {
940        let open: HashSet<SharedString> = ["a1".into()].into_iter().collect();
941        let mut rows = Vec::new();
942        flatten(tree(), 0, None, &open, &HashSet::new(), &mut rows);
943        assert!(rows.iter().all(|row| !row.expanded));
944    }
945}