Skip to main content

liora_components/
tree_select.rs

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}