1use crate::gpui_compat::element_id;
2use crate::motion::pop_in;
3use gpui::{
4 AnyElement, App, Context, IntoElement, Pixels, Render, SharedString, Window, div, prelude::*,
5 px,
6};
7use liora_core::Config;
8use liora_icons::Icon;
9use liora_icons_lucide::IconName;
10use std::collections::HashSet;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct TreeNode {
14 pub id: SharedString,
15 pub label: SharedString,
16 pub children: Vec<TreeNode>,
17}
18
19pub struct Tree {
20 data: Vec<TreeNode>,
21 expanded_keys: HashSet<SharedString>,
22 selected_keys: HashSet<SharedString>,
23 multiple: bool,
24 indent: Pixels,
25 show_checkbox: bool,
26 on_node_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App) + 'static>>,
27}
28
29impl TreeNode {
30 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
31 Self {
32 id: id.into(),
33 label: label.into(),
34 children: vec![],
35 }
36 }
37
38 pub fn child(mut self, child: TreeNode) -> Self {
39 self.children.push(child);
40 self
41 }
42}
43
44impl Tree {
45 pub fn new(data: Vec<TreeNode>) -> Self {
46 Self {
47 data,
48 expanded_keys: HashSet::new(),
49 selected_keys: HashSet::new(),
50 multiple: false,
51 indent: px(18.0),
52 show_checkbox: false,
53 on_node_click: None,
54 }
55 }
56
57 pub fn indent(mut self, indent: impl Into<Pixels>) -> Self {
58 self.indent = indent.into();
59 self
60 }
61
62 pub fn show_checkbox(mut self, show: bool) -> Self {
63 self.show_checkbox = show;
64 self
65 }
66
67 pub fn multiple(mut self, multiple: bool) -> Self {
68 self.multiple = multiple;
69 self
70 }
71
72 pub fn on_node_click(
73 mut self,
74 f: impl Fn(SharedString, &mut Window, &mut App) + 'static,
75 ) -> Self {
76 self.on_node_click = Some(Box::new(f));
77 self
78 }
79
80 fn toggle_expand(&mut self, id: SharedString, cx: &mut Context<Self>) {
81 if self.expanded_keys.contains(&id) {
82 self.expanded_keys.remove(&id);
83 } else {
84 self.expanded_keys.insert(id);
85 }
86 cx.notify();
87 }
88
89 fn select_node(&mut self, id: SharedString, window: &mut Window, cx: &mut Context<Self>) {
90 if self.multiple {
91 if self.selected_keys.contains(&id) {
92 self.selected_keys.remove(&id);
93 } else {
94 self.selected_keys.insert(id.clone());
95 }
96 } else {
97 self.selected_keys.clear();
98 self.selected_keys.insert(id.clone());
99 }
100
101 if let Some(ref on_click) = self.on_node_click {
102 (on_click)(id, window, cx);
103 }
104 cx.notify();
105 }
106
107 fn click_node(
108 &mut self,
109 id: SharedString,
110 has_children: bool,
111 window: &mut Window,
112 cx: &mut Context<Self>,
113 ) {
114 if has_children {
115 if self.expanded_keys.contains(&id) {
116 self.expanded_keys.remove(&id);
117 } else {
118 self.expanded_keys.insert(id.clone());
119 }
120 }
121 self.select_node(id, window, cx);
122 }
123
124 fn render_node(
125 &self,
126 node: &TreeNode,
127 depth: u32,
128 theme: &liora_theme::Theme,
129 cx: &Context<Self>,
130 ) -> AnyElement {
131 let id = node.id.clone();
132 let is_expanded = self.expanded_keys.contains(&id);
133 let is_selected = self.selected_keys.contains(&id);
134 let has_children = !node.children.is_empty();
135 let padding_left = px(f32::from(self.indent) * depth as f32);
136
137 div()
138 .flex()
139 .flex_col()
140 .child(
141 div()
142 .id(id.clone())
143 .cursor_pointer()
144 .flex()
145 .flex_row()
146 .items_center()
147 .gap_1()
148 .h(px(32.0))
149 .pl(padding_left)
150 .pr_4()
151 .text_color(if is_selected {
152 theme.primary.base
153 } else {
154 theme.neutral.text_1
155 })
156 .bg(if is_selected {
157 theme.primary.base.opacity(0.1)
158 } else {
159 gpui::transparent_black()
160 })
161 .hover(|s| s.bg(theme.neutral.hover))
162 .child(
163 div()
165 .flex()
166 .items_center()
167 .justify_center()
168 .w(px(20.0))
169 .id(element_id(format!("expand-{}", id.clone())))
170 .when(has_children, |s| {
171 s.on_click(cx.listener({
172 let id = id.clone();
173 move |this, _, _, cx| {
174 this.toggle_expand(id.clone(), cx);
175 cx.stop_propagation();
176 }
177 }))
178 .child(
179 Icon::new(if is_expanded {
180 IconName::ChevronDown
181 } else {
182 IconName::ChevronRight
183 })
184 .size(px(14.0))
185 .color(theme.neutral.text_3),
186 )
187 }),
188 )
189 .on_click(cx.listener({
190 let id = id.clone();
191 move |this, _, window, cx| {
192 this.click_node(id.clone(), has_children, window, cx);
193 }
194 }))
195 .child(
196 div()
197 .flex_1()
198 .id(element_id(format!("content-{}", id.clone())))
199 .child(div().text_sm().child(node.label.clone())),
200 ),
201 )
202 .when(is_expanded && has_children, |s| {
203 s.child(pop_in(
204 element_id(format!("tree-children-motion-{}", id)),
205 div().flex().flex_col().children(
206 node.children
207 .iter()
208 .map(|child| self.render_node(child, depth + 1, theme, cx)),
209 ),
210 ))
211 })
212 .into_any_element()
213 }
214}
215
216impl Render for Tree {
217 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
218 let theme = cx.global::<Config>().theme.clone();
219
220 div().flex().flex_col().w_full().children(
221 self.data
222 .iter()
223 .map(|node| self.render_node(node, 0, &theme, cx)),
224 )
225 }
226}