1use 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#[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#[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 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 pub fn reorderable(mut self, reorderable: bool) -> Self {
206 self.reorderable = reorderable;
207 self
208 }
209
210 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 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#[derive(Debug, Clone)]
250struct Visible {
251 id: SharedString,
252 label: SharedString,
253 icon: Option<Icon>,
254 disabled: bool,
255 level: u32,
257 open: bool,
258 has_children: bool,
259 parent: Option<SharedString>,
260 first_child: Option<SharedString>,
261}
262
263fn 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
294enum Move {
296 Select(SharedString),
297 Toggle(SharedString, bool),
298}
299
300fn 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 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
357fn 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
370fn 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
390fn 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#[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 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 .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#[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 .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 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 .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 .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 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 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 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}