1use crate::Input;
2use crate::gpui_compat::element_id;
3use gpui::{App, Context, Entity, Render, SharedString, Window, div, prelude::*, px};
4use liora_core::Config;
5use liora_icons::Icon;
6use liora_icons_lucide::IconName;
7use std::collections::{HashMap, HashSet};
8
9pub struct TreeSelect {
10 nodes: Vec<TreeSelectNode>,
11 selected_keys: HashSet<SharedString>,
12 disabled_keys: HashSet<SharedString>,
13 multiple: bool,
14 filterable: bool,
15 filter_input: Entity<Input>,
16 filter_query: SharedString,
17 placeholder: SharedString,
18 is_open: bool,
19 max_panel_height: gpui::Pixels,
20 on_change: Option<Box<dyn Fn(Vec<SharedString>, &mut Window, &mut App) + 'static>>,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct TreeSelectNode {
25 pub id: SharedString,
26 pub label: SharedString,
27 pub children: Vec<TreeSelectNode>,
28}
29
30impl TreeSelectNode {
31 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
32 Self {
33 id: id.into(),
34 label: label.into(),
35 children: Vec::new(),
36 }
37 }
38
39 pub fn child(mut self, child: TreeSelectNode) -> Self {
40 self.children.push(child);
41 self
42 }
43}
44
45impl TreeSelect {
46 pub fn new(nodes: Vec<TreeSelectNode>, cx: &mut Context<Self>) -> Self {
47 Self {
48 nodes,
49 selected_keys: HashSet::new(),
50 disabled_keys: HashSet::new(),
51 multiple: false,
52 filterable: false,
53 filter_input: cx.new(|cx| Input::new("", cx).placeholder("Search tree...")),
54 filter_query: SharedString::default(),
55 placeholder: "Select node".into(),
56 is_open: false,
57 max_panel_height: px(280.0),
58 on_change: None,
59 }
60 }
61
62 pub fn entity(nodes: Vec<TreeSelectNode>, cx: &mut App) -> Entity<Self> {
63 cx.new(|cx| Self::new(nodes, cx))
64 }
65
66 pub fn selected(mut self, ids: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
67 self.selected_keys = ids.into_iter().map(Into::into).collect();
68 self
69 }
70
71 pub fn disabled_keys(mut self, ids: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
72 self.disabled_keys = ids.into_iter().map(Into::into).collect();
73 self
74 }
75
76 pub fn multiple(mut self, multiple: bool) -> Self {
77 self.multiple = multiple;
78 self
79 }
80
81 pub fn filterable(mut self, filterable: bool) -> Self {
82 self.filterable = filterable;
83 self
84 }
85
86 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
87 self.placeholder = placeholder.into();
88 self
89 }
90
91 pub fn max_panel_height(mut self, height: impl Into<gpui::Pixels>) -> Self {
92 self.max_panel_height = height.into().max(px(120.0));
93 self
94 }
95
96 pub fn on_change(
97 mut self,
98 cb: impl Fn(Vec<SharedString>, &mut Window, &mut App) + 'static,
99 ) -> Self {
100 self.on_change = Some(Box::new(cb));
101 self
102 }
103
104 pub fn selected_keys(&self) -> Vec<SharedString> {
105 let mut keys = self.selected_keys.iter().cloned().collect::<Vec<_>>();
106 keys.sort();
107 keys
108 }
109
110 pub fn set_filter_query(&mut self, query: impl Into<SharedString>, cx: &mut Context<Self>) {
111 let query = query.into();
112 if self.filter_query == query {
113 return;
114 }
115 self.filter_query = query;
116 cx.notify();
117 }
118
119 fn toggle_open(&mut self, cx: &mut Context<Self>) {
120 self.is_open = !self.is_open;
121 cx.notify();
122 }
123
124 fn select_node(&mut self, id: SharedString, window: &mut Window, cx: &mut Context<Self>) {
125 if self.disabled_keys.contains(&id) {
126 return;
127 }
128 if self.multiple {
129 if !self.selected_keys.remove(&id) {
130 self.selected_keys.insert(id);
131 }
132 } else {
133 self.selected_keys.clear();
134 self.selected_keys.insert(id);
135 self.is_open = false;
136 }
137 let selected = self.selected_keys();
138 if let Some(ref cb) = self.on_change {
139 cb(selected, window, cx);
140 }
141 cx.notify();
142 }
143
144 fn selected_label(&self) -> SharedString {
145 let labels = node_label_map(&self.nodes);
146 if self.selected_keys.is_empty() {
147 return self.placeholder.clone();
148 }
149 let mut selected = self
150 .selected_keys
151 .iter()
152 .filter_map(|id| labels.get(id).cloned())
153 .collect::<Vec<_>>();
154 selected.sort();
155 SharedString::from(selected.join(if self.multiple { ", " } else { "" }))
156 }
157
158 fn render_nodes(
159 &self,
160 nodes: &[TreeSelectNode],
161 depth: usize,
162 window: &mut Window,
163 cx: &mut Context<Self>,
164 ) -> Vec<gpui::AnyElement> {
165 nodes
166 .iter()
167 .filter(|node| node_matches_filter(node, self.filter_query.as_ref()))
168 .flat_map(|node| {
169 let mut out = Vec::new();
170 out.push(self.render_node_row(node, depth, window, cx));
171 out.extend(self.render_nodes(&node.children, depth + 1, window, cx));
172 out
173 })
174 .collect()
175 }
176
177 fn render_node_row(
178 &self,
179 node: &TreeSelectNode,
180 depth: usize,
181 _window: &mut Window,
182 cx: &mut Context<Self>,
183 ) -> gpui::AnyElement {
184 let theme = cx.global::<Config>().theme.clone();
185 let id = node.id.clone();
186 let selected = self.selected_keys.contains(&id);
187 let disabled = self.disabled_keys.contains(&id);
188 let has_children = !node.children.is_empty();
189 let multiple = self.multiple;
190 div()
191 .id(element_id(format!("tree-select-node-{}", id)))
192 .flex()
193 .items_center()
194 .gap_2()
195 .min_h(px(30.0))
196 .pl(px(10.0 + depth as f32 * 18.0))
197 .pr_3()
198 .rounded_sm()
199 .text_color(if disabled {
200 theme.neutral.text_disabled
201 } else if selected {
202 theme.primary.base
203 } else {
204 theme.neutral.text_1
205 })
206 .bg(if selected {
207 theme.primary.base.opacity(0.1)
208 } else {
209 gpui::transparent_black()
210 })
211 .when(!disabled, |s| {
212 s.cursor_pointer().hover(|s| s.bg(theme.neutral.hover))
213 })
214 .when(disabled, |s| s.cursor_not_allowed().opacity(0.58))
215 .child(
216 Icon::new(if has_children {
217 IconName::ChevronRight
218 } else {
219 IconName::Minus
220 })
221 .size(px(13.0))
222 .color(theme.neutral.text_3),
223 )
224 .when(multiple, |s| {
225 s.child(
226 Icon::new(if selected {
227 IconName::Check
228 } else {
229 IconName::Square
230 })
231 .size(px(15.0))
232 .color(if selected {
233 theme.primary.base
234 } else {
235 theme.neutral.icon
236 }),
237 )
238 })
239 .when(!multiple && selected, |s| {
240 s.child(
241 Icon::new(IconName::Check)
242 .size(px(15.0))
243 .color(theme.primary.base),
244 )
245 })
246 .child(div().flex_1().text_sm().child(node.label.clone()))
247 .on_click(cx.listener(move |this, _, window, cx| {
248 this.select_node(id.clone(), window, cx);
249 }))
250 .into_any_element()
251 }
252}
253
254impl Render for TreeSelect {
255 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
256 let theme = cx.global::<Config>().theme.clone();
257 let filter_input = self.filter_input.clone();
258 let view = cx.entity().clone();
259 cx.update_entity(&filter_input, |input, cx| {
260 input.set_placeholder("Search tree...", cx);
261 input.set_on_change(move |value, cx| {
262 view.update(cx, |view: &mut TreeSelect, cx| {
263 view.set_filter_query(value.to_string(), cx)
264 });
265 });
266 });
267
268 let label = self.selected_label();
269 div()
270 .id(liora_core::unique_id("tree-select"))
271 .relative()
272 .flex()
273 .flex_col()
274 .gap_2()
275 .child(
276 div()
277 .id("tree-select-trigger")
278 .flex()
279 .items_center()
280 .justify_between()
281 .min_h(px(34.0))
282 .rounded_md()
283 .border_1()
284 .border_color(if self.is_open {
285 theme.primary.base
286 } else {
287 theme.neutral.border
288 })
289 .bg(theme.neutral.card)
290 .px_3()
291 .cursor_pointer()
292 .child(
293 div()
294 .truncate()
295 .text_sm()
296 .text_color(if self.selected_keys.is_empty() {
297 theme.neutral.text_3
298 } else {
299 theme.neutral.text_1
300 })
301 .child(label),
302 )
303 .child(
304 Icon::new(if self.is_open {
305 IconName::ChevronUp
306 } else {
307 IconName::ChevronDown
308 })
309 .size(px(16.0))
310 .color(theme.neutral.icon),
311 )
312 .on_click(cx.listener(|this, _, _, cx| this.toggle_open(cx))),
313 )
314 .when(self.is_open, |s| {
315 s.child(
316 div()
317 .id("tree-select-panel")
318 .rounded_md()
319 .border_1()
320 .border_color(theme.neutral.border)
321 .bg(theme.neutral.card)
322 .shadow_lg()
323 .p_2()
324 .max_h(self.max_panel_height)
325 .overflow_y_scroll()
326 .flex()
327 .flex_col()
328 .gap_1()
329 .when(self.filterable, |s| s.child(filter_input))
330 .children(self.render_nodes(&self.nodes, 0, window, cx)),
331 )
332 })
333 }
334}
335
336pub fn node_label_map(nodes: &[TreeSelectNode]) -> HashMap<SharedString, String> {
337 let mut map = HashMap::new();
338 fn walk(node: &TreeSelectNode, map: &mut HashMap<SharedString, String>) {
339 map.insert(node.id.clone(), node.label.to_string());
340 for child in &node.children {
341 walk(child, map);
342 }
343 }
344 for node in nodes {
345 walk(node, &mut map);
346 }
347 map
348}
349
350pub fn node_matches_filter(node: &TreeSelectNode, query: &str) -> bool {
351 if query.trim().is_empty() {
352 return true;
353 }
354 let query = query.to_lowercase();
355 node.label.to_lowercase().contains(&query)
356 || node.id.to_lowercase().contains(&query)
357 || node
358 .children
359 .iter()
360 .any(|child| node_matches_filter(child, &query))
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 fn nodes() -> Vec<TreeSelectNode> {
367 vec![
368 TreeSelectNode::new("docs", "Docs")
369 .child(TreeSelectNode::new("quick-start", "Quick Start")),
370 TreeSelectNode::new("charts", "Charts"),
371 ]
372 }
373 #[test]
374 fn tree_select_filter_keeps_matching_parents() {
375 assert!(node_matches_filter(&nodes()[0], "quick"));
376 assert!(!node_matches_filter(&nodes()[1], "quick"));
377 }
378 #[test]
379 fn tree_select_label_map_flattens_tree() {
380 let labels = node_label_map(&nodes());
381 assert_eq!(
382 labels.get("quick-start").map(String::as_str),
383 Some("Quick Start")
384 );
385 }
386}