Skip to main content

gpui_kit/data/
tree.rs

1//! A hierarchy whose open branches are caller-owned.
2//!
3//! The tree reports the node that was activated and the state its disclosure
4//! should take next. It renders exactly the set the caller passed to
5//! [`Tree::expanded`], so a host that refuses to open a branch leaves it shut.
6//!
7//! A collapsed node renders none of its children, and publishes none of them
8//! either, so asserting that a child is absent means something.
9//!
10//! # What a large hierarchy costs
11//!
12//! What is on screen depends on what is open, so the tree first flattens the
13//! hierarchy to the rows a reader could see and then draws from that. With
14//! [`Tree::visible_rows`] it draws only the ones that fit, so a hierarchy with
15//! ten thousand disclosed rows lays out a viewport's worth. Without it the
16//! tree sizes itself to its content and every disclosed row is laid out.
17//!
18//! Flattening still walks the whole hierarchy each frame, because the caller
19//! hands the tree the nodes rather than a way to ask for one. That is data,
20//! not elements: a [`TreeNode`] holds two strings, an element holds a layout.
21//!
22//! The tree's semantic node carries the number of disclosed rows in `value`,
23//! which is what keeps three different absences apart: a node under a shut
24//! branch is not disclosed, a disclosed node outside the viewport is counted
25//! but not published, and a node that is not in the data at all is neither.
26//!
27//! A bounded tree can draw a node whose parent has scrolled off the top. The
28//! node still reports the parent it has, because that is what is true of it,
29//! so a walk down from the tree's own node will not reach it and a test that
30//! wants it should name it. Its `level` says how deep it sits either way.
31
32use std::f32::consts::FRAC_PI_2;
33use std::ops::Range;
34use std::rc::Rc;
35
36use gpui::{
37    App, InteractiveElement, IntoElement, ListSizingBehavior, ParentElement, RenderOnce,
38    ScrollStrategy, SharedString, StatefulInteractiveElement, Styled, Transformation, Window, div,
39    point, prelude::FluentBuilder, px, radians, uniform_list,
40};
41use gpui_kit_assets::{Icon, icon};
42use gpui_kit_semantics::{NodeSpec, Role, Semantic};
43use gpui_kit_theme::{ActiveTheme, ControlSize, Space, Theme, TypeScale};
44
45use crate::data::viewport::scroll_handle;
46use crate::display::icon::flips;
47use crate::foundation::direction::{ActiveDirection, DirectionalExt, LayoutDirection};
48use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
49use crate::interaction::dnd::{
50    self, DragItem, DropAxis, DropIntent, DropPosition, MakingWay, RowTarget, SurfaceDrag,
51};
52
53type ToggleHandler = Rc<dyn Fn(SharedString, bool, &mut Window, &mut App)>;
54type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
55type MoveHandler = Rc<dyn Fn(&DropIntent, &mut Window, &mut App)>;
56type Accepts = Rc<dyn Fn(&DragItem, &DropPosition) -> bool>;
57
58/// One node, identified by business identity rather than by its place in the
59/// hierarchy, so moving a branch does not rename what hangs under it.
60#[derive(Debug, Clone)]
61pub struct TreeNode {
62    id: SharedString,
63    label: SharedString,
64    icon: Option<Icon>,
65    disabled: bool,
66    children: Vec<TreeNode>,
67}
68
69impl TreeNode {
70    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
71        Self {
72            id: id.into(),
73            label: label.into(),
74            icon: None,
75            disabled: false,
76            children: Vec::new(),
77        }
78    }
79
80    pub fn child(mut self, child: TreeNode) -> Self {
81        self.children.push(child);
82        self
83    }
84
85    pub fn children(mut self, children: impl IntoIterator<Item = TreeNode>) -> Self {
86        self.children.extend(children);
87        self
88    }
89
90    pub fn icon(mut self, icon: Icon) -> Self {
91        self.icon = Some(icon);
92        self
93    }
94
95    pub fn disabled(mut self, disabled: bool) -> Self {
96        self.disabled = disabled;
97        self
98    }
99}
100
101/// A disclosure hierarchy.
102#[derive(IntoElement)]
103pub struct Tree {
104    ident: Ident,
105    nodes: Vec<TreeNode>,
106    expanded: Vec<SharedString>,
107    selected: Option<SharedString>,
108    visible_rows: Option<usize>,
109    size: ControlSize,
110    disabled: bool,
111    on_toggle: Option<ToggleHandler>,
112    on_select: Option<SelectHandler>,
113    reorderable: bool,
114    accepts: Option<Accepts>,
115    on_move: Option<MoveHandler>,
116}
117
118impl std::fmt::Debug for Tree {
119    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        formatter
121            .debug_struct("Tree")
122            .field("ident", &self.ident)
123            .field("nodes", &self.nodes.len())
124            .field("expanded", &self.expanded)
125            .field("selected", &self.selected)
126            .field("disabled", &self.disabled)
127            .finish()
128    }
129}
130
131impl Tree {
132    pub fn new(ident: impl Into<Ident>) -> Self {
133        Self {
134            ident: ident.into(),
135            nodes: Vec::new(),
136            expanded: Vec::new(),
137            selected: None,
138            visible_rows: None,
139            size: ControlSize::Md,
140            disabled: false,
141            on_toggle: None,
142            on_select: None,
143            reorderable: false,
144            accepts: None,
145            on_move: None,
146        }
147    }
148
149    pub fn node(mut self, node: TreeNode) -> Self {
150        self.nodes.push(node);
151        self
152    }
153
154    pub fn nodes(mut self, nodes: impl IntoIterator<Item = TreeNode>) -> Self {
155        self.nodes.extend(nodes);
156        self
157    }
158
159    pub fn expanded(mut self, ids: impl IntoIterator<Item = SharedString>) -> Self {
160        self.expanded = ids.into_iter().collect();
161        self
162    }
163
164    pub fn expanded_ids<S: AsRef<str>>(mut self, ids: &[S]) -> Self {
165        self.expanded = ids
166            .iter()
167            .map(|id| SharedString::from(id.as_ref().to_string()))
168            .collect();
169        self
170    }
171
172    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
173        self.selected = Some(id.into());
174        self
175    }
176
177    /// Bounds the viewport to `rows` rows, which is what lets the tree skip
178    /// the disclosed rows it does not show.
179    ///
180    /// Without it the tree sizes itself to its content, which is the right
181    /// answer for a hierarchy a reader takes in whole and the wrong one for a
182    /// workspace with ten thousand files open.
183    pub fn visible_rows(mut self, rows: usize) -> Self {
184        self.visible_rows = Some(rows);
185        self
186    }
187
188    pub fn on_toggle(
189        mut self,
190        handler: impl Fn(SharedString, bool, &mut Window, &mut App) + 'static,
191    ) -> Self {
192        self.on_toggle = Some(Rc::new(handler));
193        self
194    }
195
196    pub fn on_select(
197        mut self,
198        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
199    ) -> Self {
200        self.on_select = Some(Rc::new(handler));
201        self
202    }
203
204    /// Lets a node be picked up and put somewhere else in the hierarchy.
205    pub fn reorderable(mut self, reorderable: bool) -> Self {
206        self.reorderable = reorderable;
207        self
208    }
209
210    /// Whether this tree takes a payload, and where.
211    ///
212    /// Structural impossibilities are refused before this is consulted: a node
213    /// cannot be moved inside itself or below one of its own descendants,
214    /// because the descendant travels with it. Everything else — which kinds
215    /// of node may hold which — is policy, and policy is the caller's.
216    pub fn accepts(
217        mut self,
218        predicate: impl Fn(&DragItem, &DropPosition) -> bool + 'static,
219    ) -> Self {
220        self.accepts = Some(Rc::new(predicate));
221        self
222    }
223
224    /// Reports where a dropped node should go. The tree does not move it.
225    pub fn on_move(
226        mut self,
227        handler: impl Fn(&DropIntent, &mut Window, &mut App) + 'static,
228    ) -> Self {
229        self.on_move = Some(Rc::new(handler));
230        self
231    }
232}
233
234impl Disableable for Tree {
235    fn disabled(mut self, disabled: bool) -> Self {
236        self.disabled = disabled;
237        self
238    }
239}
240
241impl Sizable for Tree {
242    fn control_size(mut self, size: ControlSize) -> Self {
243        self.size = size;
244        self
245    }
246}
247
248/// One node as it is actually shown: what the keyboard can reach.
249#[derive(Debug, Clone)]
250struct Visible {
251    id: SharedString,
252    label: SharedString,
253    icon: Option<Icon>,
254    disabled: bool,
255    /// Root nodes are level 1, matching how assistive technology counts.
256    level: u32,
257    open: bool,
258    has_children: bool,
259    parent: Option<SharedString>,
260    first_child: Option<SharedString>,
261}
262
263/// The nodes a frame shows, in the order the keyboard walks them.
264///
265/// A collapsed branch contributes only itself, which is why a move never lands
266/// on something the typist cannot see.
267fn flatten(
268    nodes: &[TreeNode],
269    expanded: &[SharedString],
270    level: u32,
271    parent: Option<&SharedString>,
272    out: &mut Vec<Visible>,
273) {
274    for node in nodes {
275        let open = expanded.contains(&node.id);
276        let has_children = !node.children.is_empty();
277        out.push(Visible {
278            id: node.id.clone(),
279            label: node.label.clone(),
280            icon: node.icon,
281            disabled: node.disabled,
282            level,
283            open: open && has_children,
284            has_children,
285            parent: parent.cloned(),
286            first_child: node.children.first().map(|child| child.id.clone()),
287        });
288        if open && has_children {
289            flatten(&node.children, expanded, level + 1, Some(&node.id), out);
290        }
291    }
292}
293
294/// What a keystroke reports: a selection, a disclosure change, or nothing.
295enum Move {
296    Select(SharedString),
297    Toggle(SharedString, bool),
298}
299
300/// A horizontal arrow in a tree means "toward the children" or "toward the
301/// parent", not "toward an edge of the screen": a branch opens in the
302/// direction the indent grows, and the indent grows the way the tree reads. So
303/// the two arrows swap once the interface reads right to left, while up, down,
304/// home and end mean the same thing either way.
305fn keystroke_move(
306    key: &str,
307    direction: LayoutDirection,
308    visible: &[Visible],
309    selected: Option<&SharedString>,
310) -> Option<Move> {
311    let at = visible
312        .iter()
313        .position(|node| Some(&node.id) == selected)
314        .filter(|_| selected.is_some());
315    let key = match direction.arrow_step(key) {
316        Some(1) => "toward-children",
317        Some(_) => "toward-parent",
318        None => key,
319    };
320    match key {
321        "up" | "down" => {
322            let delta: isize = if key == "down" { 1 } else { -1 };
323            let from = match at {
324                Some(at) => at as isize + delta,
325                // Entering from outside, a move lands on the end it travels
326                // away from.
327                None if delta > 0 => 0,
328                None => visible.len() as isize - 1,
329            };
330            step(visible, from, delta).map(Move::Select)
331        }
332        "home" => step(visible, 0, 1).map(Move::Select),
333        "end" => step(visible, visible.len() as isize - 1, -1).map(Move::Select),
334        "toward-children" => {
335            let node = visible.get(at?)?;
336            if node.has_children && !node.open {
337                Some(Move::Toggle(node.id.clone(), true))
338            } else {
339                node.first_child
340                    .clone()
341                    .filter(|_| node.open)
342                    .map(Move::Select)
343            }
344        }
345        "toward-parent" => {
346            let node = visible.get(at?)?;
347            if node.has_children && node.open {
348                Some(Move::Toggle(node.id.clone(), false))
349            } else {
350                node.parent.clone().map(Move::Select)
351            }
352        }
353        _ => None,
354    }
355}
356
357/// The first node from `from` in `delta`'s direction that accepts selection.
358fn step(visible: &[Visible], from: isize, delta: isize) -> Option<SharedString> {
359    let mut index = from;
360    while index >= 0 && (index as usize) < visible.len() {
361        let node = &visible[index as usize];
362        if !node.disabled {
363            return Some(node.id.clone());
364        }
365        index += delta;
366    }
367    None
368}
369
370/// The node named `id`, wherever it sits.
371fn find<'a>(nodes: &'a [TreeNode], id: &SharedString) -> Option<&'a TreeNode> {
372    for node in nodes {
373        if &node.id == id {
374            return Some(node);
375        }
376        if let Some(found) = find(&node.children, id) {
377            return Some(found);
378        }
379    }
380    None
381}
382
383fn collect(node: &TreeNode, out: &mut Vec<SharedString>) {
384    out.push(node.id.clone());
385    for child in &node.children {
386        collect(child, out);
387    }
388}
389
390/// A node and everything hanging under it.
391///
392/// A node cannot be moved into, before, or after anything in here: its
393/// descendants travel with it, so the destination would end up inside the
394/// thing being moved. That is a structural impossibility rather than a policy,
395/// which is why the tree judges it instead of asking the caller.
396fn subtree(nodes: &[TreeNode], id: &SharedString) -> Vec<SharedString> {
397    let mut ids = Vec::new();
398    if let Some(node) = find(nodes, id) {
399        collect(node, &mut ids);
400    }
401    ids
402}
403
404/// What a node needs to take part in a move.
405#[derive(Clone)]
406struct Reorder {
407    surface: SharedString,
408    drag: Option<SurfaceDrag>,
409    accepts: Accepts,
410    on_drop: MoveHandler,
411}
412
413impl Tree {
414    fn reorder(&self, window: &mut Window, cx: &mut App) -> Option<Reorder> {
415        if self.disabled || !self.reorderable {
416            return None;
417        }
418        let on_drop = self.on_move.clone()?;
419        let surface = self.ident.semantic_id();
420        let nodes = self.nodes.clone();
421        let caller = self.accepts.clone();
422        let own = surface.clone();
423        let accepts: Accepts = Rc::new(move |item: &DragItem, position: &DropPosition| {
424            if item.source == own && subtree(&nodes, &item.id).contains(position.anchor()) {
425                return false;
426            }
427            match &caller {
428                Some(caller) => caller(item, position),
429                None => item.source == own,
430            }
431        });
432        Some(Reorder {
433            drag: dnd::surface_drag(&surface, window, cx),
434            surface,
435            accepts,
436            on_drop,
437        })
438    }
439}
440
441impl RenderOnce for Tree {
442    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
443        let theme = cx.theme().clone();
444        let metrics = theme.control.get(self.size);
445        let reorder = self.reorder(window, cx);
446        let mut visible = Vec::new();
447        flatten(&self.nodes, &self.expanded, 1, None, &mut visible);
448
449        let mut stack = div().id(self.ident.element_id()).column().w_full();
450
451        // A tree that draws only its viewport can still be walked end to end,
452        // because the keyboard moves over the flattened rows rather than over
453        // the ones that happen to be built. A move that lands off screen
454        // brings the row it named into view.
455        let rows_ident = self.ident.child("rows");
456        let scroll = self.visible_rows.map(|_| scroll_handle(&rows_ident, cx));
457
458        if !self.disabled && (self.on_select.is_some() || self.on_toggle.is_some()) {
459            let nodes = visible.clone();
460            let selected = self.selected.clone();
461            let select = self.on_select.clone();
462            let toggle = self.on_toggle.clone();
463            let direction = cx.layout_direction();
464            let scroll = scroll.clone();
465            stack = stack.on_key_down(move |event, window, cx| {
466                let Some(next) = keystroke_move(
467                    event.keystroke.key.as_str(),
468                    direction,
469                    &nodes,
470                    selected.as_ref(),
471                ) else {
472                    return;
473                };
474                match next {
475                    Move::Select(id) => {
476                        if let (Some(scroll), Some(at)) =
477                            (scroll.as_ref(), nodes.iter().position(|node| node.id == id))
478                        {
479                            scroll.scroll_to_item(at, ScrollStrategy::Nearest);
480                            window.refresh();
481                        }
482                        if Some(&id) == selected.as_ref() {
483                            return;
484                        }
485                        let Some(handler) = select.as_ref() else {
486                            return;
487                        };
488                        handler(id, window, cx);
489                    }
490                    Move::Toggle(id, open) => {
491                        let Some(handler) = toggle.as_ref() else {
492                            return;
493                        };
494                        handler(id, open, window, cx);
495                    }
496                }
497                cx.stop_propagation();
498            });
499        }
500
501        let rows = Rows {
502            ident: self.ident.clone(),
503            selected: self.selected.clone(),
504            disabled: self.disabled,
505            size: self.size,
506            on_select: self.on_select.clone(),
507            on_toggle: self.on_toggle.clone(),
508        };
509        let count = visible.len();
510
511        match (self.visible_rows, scroll) {
512            (Some(bound), Some(scroll)) => {
513                let theme = theme.clone();
514                let icon_size = metrics.icon_size;
515                let height = theme.control.get(self.size).height;
516                stack = stack.child(
517                    uniform_list(
518                        rows_ident.element_id(),
519                        count,
520                        move |range: Range<usize>, window, cx| {
521                            range
522                                .map(|index| {
523                                    rows.node_element(
524                                        &visible[index],
525                                        index,
526                                        &theme,
527                                        icon_size,
528                                        reorder.as_ref(),
529                                        window,
530                                        cx,
531                                    )
532                                })
533                                .collect::<Vec<_>>()
534                        },
535                    )
536                    .track_scroll(&scroll)
537                    .w_full()
538                    .with_sizing_behavior(ListSizingBehavior::Auto)
539                    // A hierarchy shorter than the bound ends where its last
540                    // row ends, so a cap is not a claim about how much there
541                    // is to disclose.
542                    .h(px(height * count.min(bound) as f32)),
543                );
544            }
545            _ => {
546                for (index, node) in visible.iter().enumerate() {
547                    stack = stack.child(rows.node_element(
548                        node,
549                        index,
550                        &theme,
551                        metrics.icon_size,
552                        reorder.as_ref(),
553                        window,
554                        cx,
555                    ));
556                }
557            }
558        }
559
560        stack.semantic_in(
561            cx,
562            NodeSpec::new(self.ident.semantic_id(), Role::Tree).value(count.to_string()),
563        )
564    }
565}
566
567/// Everything a node needs that does not come from the node itself.
568///
569/// A virtualized tree builds its rows inside a `'static` closure, which cannot
570/// borrow the tree, so the few fields a row reads travel into the closure by
571/// value and the unbounded path reads the same ones.
572#[derive(Clone)]
573struct Rows {
574    ident: Ident,
575    selected: Option<SharedString>,
576    disabled: bool,
577    size: ControlSize,
578    on_select: Option<SelectHandler>,
579    on_toggle: Option<ToggleHandler>,
580}
581
582impl Rows {
583    #[allow(clippy::too_many_arguments)]
584    fn node_element(
585        &self,
586        node: &Visible,
587        index: usize,
588        theme: &Theme,
589        icon_size: f32,
590        reorder: Option<&Reorder>,
591        window: &mut Window,
592        cx: &mut App,
593    ) -> gpui::AnyElement {
594        let ident = self.ident.child(node.id.as_ref());
595        let selected = self.selected.as_ref() == Some(&node.id);
596        let disabled = self.disabled || node.disabled;
597        let draggable = reorder.filter(|_| !disabled);
598        let drag = draggable.and_then(|reorder| reorder.drag.as_ref());
599        let carried = drag.is_some_and(|drag| drag.carries(&node.id));
600        let landing = drag.and_then(|drag| drag.indicator_for(&node.id));
601        let selectable = !disabled && self.on_select.is_some();
602        let toggleable = !disabled && node.has_children && self.on_toggle.is_some();
603        let color = if disabled {
604            theme.colors.text_faint
605        } else {
606            theme.colors.text
607        };
608        let direction = cx.layout_direction();
609
610        let chevron = node.has_children.then(|| {
611            let toggle = ident.child("toggle");
612            let mut glyph = div()
613                .id(toggle.element_id())
614                .row()
615                .flex_none()
616                .size(px(icon_size))
617                .child(
618                    icon(Icon::AltArrowRight)
619                        .size(px(icon_size))
620                        .text_color(theme.colors.text_muted)
621                        .when(node.open, |glyph| {
622                            glyph.with_transformation(Transformation::rotate(radians(FRAC_PI_2)))
623                        })
624                        // An open chevron already points down, which is the
625                        // same way down in either reading direction, so only
626                        // the shut one turns around.
627                        .when(
628                            !node.open && flips(Icon::AltArrowRight, direction),
629                            |glyph| {
630                                glyph.with_transformation(Transformation::scale(gpui::size(
631                                    -1.0, 1.0,
632                                )))
633                            },
634                        ),
635                )
636                .when(toggleable, |element| {
637                    element
638                        .cursor_pointer()
639                        .tab_index(0)
640                        .pressable(cx)
641                        .focus_ring(theme)
642                });
643
644            if let (true, Some(handler)) = (toggleable, self.on_toggle.clone()) {
645                let id = node.id.clone();
646                let open = node.open;
647                let keyed = Rc::clone(&handler);
648                let keyed_id = id.clone();
649                glyph = glyph.on_click(move |_, window, cx| {
650                    handler(id.clone(), !open, window, cx);
651                    // A disclosure is not a selection, so the row underneath
652                    // must not also report one.
653                    cx.stop_propagation();
654                });
655                glyph = glyph.on_key_down(move |event, window, cx| {
656                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
657                        keyed(keyed_id.clone(), !open, window, cx);
658                        cx.stop_propagation();
659                    }
660                });
661            }
662
663            glyph.semantic_in(
664                cx,
665                NodeSpec::new(toggle.semantic_id(), Role::Button)
666                    .parent(ident.semantic_id())
667                    .text(node.label.clone())
668                    .expanded(node.open)
669                    .disabled(!toggleable),
670            )
671        });
672
673        let mut row = div()
674            .id(ident.element_id())
675            .row_reading(direction)
676            .w_full()
677            .h(px(theme.control.get(self.size).height))
678            .pe(direction, px(theme.space(Space::Sm)))
679            // The indent is the only thing that says how deep a node sits, so
680            // it steps once per level from the edge reading starts at.
681            .ps(
682                direction,
683                px(theme.space(Space::Sm)
684                    + node.level.saturating_sub(1) as f32 * theme.space(Space::Md)),
685            )
686            .gap(px(theme.space(Space::Xs)))
687            .text_color(color)
688            .when(selected, |element| element.bg(theme.colors.selected))
689            .when(disabled, |element| element.opacity(theme.opacity.disabled))
690            .when(carried, |element| element.opacity(theme.opacity.muted))
691            .when(selectable, |element| {
692                element
693                    .cursor_pointer()
694                    .tab_index(0)
695                    .pressable(cx)
696                    .when(!selected, |element| {
697                        element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
698                    })
699                    .focus_ring(theme)
700            })
701            .children(chevron)
702            // A leaf still lines up with its siblings, which is what makes the
703            // indent readable as depth rather than as decoration.
704            .when(!node.has_children, |element| {
705                element.child(div().flex_none().size(px(icon_size)))
706            })
707            .children(
708                node.icon
709                    .map(|glyph| icon(glyph).size(px(icon_size)).text_color(color)),
710            )
711            .child(
712                text(theme, TypeScale::Body, node.label.clone())
713                    .flex_1()
714                    .overflow_hidden()
715                    .text_start(direction)
716                    .text_color(color),
717            )
718            .children(landing.map(|(position, accepted)| {
719                dnd::indicator(&position, accepted, DropAxis::Vertical, cx)
720            }));
721
722        if let (true, Some(handler)) = (selectable, self.on_select.clone()) {
723            let id = node.id.clone();
724            row = row.on_click(move |_, window, cx| handler(id.clone(), window, cx));
725        }
726
727        if let Some(reorder) = draggable {
728            let mut item =
729                DragItem::new(reorder.surface.clone(), node.id.clone(), node.label.clone());
730            if let Some(glyph) = node.icon {
731                item = item.icon(glyph);
732            }
733            row = dnd::draggable(row, item);
734            row = dnd::drop_target(
735                row,
736                RowTarget {
737                    surface: reorder.surface.clone(),
738                    id: node.id.clone(),
739                    index,
740                    // Only a branch can be entered; a leaf offers the slots
741                    // beside it and nothing else.
742                    allow_into: node.has_children,
743                    axis: DropAxis::Vertical,
744                    accepts: Rc::clone(&reorder.accepts),
745                    on_drop: Rc::clone(&reorder.on_drop),
746                },
747            );
748        }
749
750        let mut spec = NodeSpec::new(ident.semantic_id(), Role::TreeItem)
751            .parent(
752                node.parent
753                    .as_ref()
754                    .map_or(self.ident.semantic_id(), |parent| {
755                        self.ident.child(parent.as_ref()).semantic_id()
756                    }),
757            )
758            .text(node.label.clone())
759            .selected(selected)
760            .disabled(disabled)
761            .level(node.level);
762        // Only a node that has something to disclose claims a disclosure
763        // state; a leaf that reported `expanded: false` would look shut.
764        if node.has_children {
765            spec = spec.expanded(node.open);
766        }
767
768        let row = row.semantic_in(cx, spec);
769        match draggable {
770            Some(reorder) => {
771                let shift = reorder
772                    .drag
773                    .as_ref()
774                    .filter(|drag| drag.makes_way(index))
775                    .map_or(px(0.0), |_| dnd::make_way_gap(cx, DropAxis::Vertical));
776                row.make_way(ident.semantic_id(), point(px(0.0), shift), window, cx)
777                    .into_any_element()
778            }
779            None => row.into_any_element(),
780        }
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    fn sample() -> Vec<TreeNode> {
789        vec![
790            TreeNode::new("workspace", "Workspace").children([
791                TreeNode::new("src", "src").children([TreeNode::new("lib", "lib.rs")]),
792                TreeNode::new("docs", "docs"),
793            ]),
794            TreeNode::new("target", "target").disabled(true),
795        ]
796    }
797
798    fn visible(expanded: &[&str]) -> Vec<Visible> {
799        let expanded: Vec<SharedString> = expanded
800            .iter()
801            .map(|id| SharedString::from(id.to_string()))
802            .collect();
803        let mut out = Vec::new();
804        flatten(&sample(), &expanded, 1, None, &mut out);
805        out
806    }
807
808    #[test]
809    fn a_collapsed_branch_contributes_only_itself() {
810        let nodes = visible(&[]);
811        let ids: Vec<&str> = nodes.iter().map(|node| node.id.as_ref()).collect();
812        assert_eq!(ids, vec!["workspace", "target"]);
813    }
814
815    #[test]
816    fn an_open_branch_levels_its_children_one_deeper() {
817        let nodes = visible(&["workspace"]);
818        assert_eq!(nodes[0].level, 1);
819        assert_eq!(nodes[1].level, 2);
820        assert_eq!(nodes[1].parent.as_deref(), Some("workspace"));
821    }
822
823    #[test]
824    fn a_move_down_skips_a_refusal_and_stops_at_the_end() {
825        let nodes = visible(&["workspace"]);
826        let from = SharedString::from("docs");
827        // `target` refuses selection and is the last node, so the move lands
828        // nowhere rather than wrapping.
829        assert!(
830            keystroke_move("down", LayoutDirection::LeftToRight, &nodes, Some(&from)).is_none()
831        );
832    }
833
834    #[test]
835    fn right_opens_a_shut_branch_and_then_descends() {
836        let shut = visible(&[]);
837        let workspace = SharedString::from("workspace");
838        match keystroke_move(
839            "right",
840            LayoutDirection::LeftToRight,
841            &shut,
842            Some(&workspace),
843        ) {
844            Some(Move::Toggle(id, next)) => {
845                assert_eq!(id.as_ref(), "workspace");
846                assert!(next);
847            }
848            _ => panic!("right must open a shut branch"),
849        }
850
851        let open = visible(&["workspace"]);
852        match keystroke_move(
853            "right",
854            LayoutDirection::LeftToRight,
855            &open,
856            Some(&workspace),
857        ) {
858            Some(Move::Select(id)) => assert_eq!(id.as_ref(), "src"),
859            _ => panic!("right must descend into an open branch"),
860        }
861    }
862
863    #[test]
864    fn left_shuts_an_open_branch_and_otherwise_ascends() {
865        let open = visible(&["workspace"]);
866        let src = SharedString::from("src");
867        match keystroke_move("left", LayoutDirection::LeftToRight, &open, Some(&src)) {
868            Some(Move::Select(id)) => assert_eq!(id.as_ref(), "workspace"),
869            _ => panic!("left must ascend from a leaf"),
870        }
871
872        let deeper = visible(&["workspace", "src"]);
873        match keystroke_move("left", LayoutDirection::LeftToRight, &deeper, Some(&src)) {
874            Some(Move::Toggle(id, next)) => {
875                assert_eq!(id.as_ref(), "src");
876                assert!(!next);
877            }
878            _ => panic!("left must shut an open branch"),
879        }
880    }
881}