Skip to main content

photon_ui/components/
tree_view.rs

1use crossterm::event::KeyCode;
2
3use crate::{
4    Component,
5    Event,
6    Focusable,
7    InputResult,
8    RenderError,
9    Rendered,
10    theme::{
11        Style,
12        Theme,
13        stylize,
14    },
15};
16
17/// A single node in a tree view.
18///
19/// Nodes can have children and can be expanded or collapsed.
20pub struct TreeNode {
21    label: String,
22    children: Vec<TreeNode>,
23    expanded: bool,
24}
25
26impl TreeNode {
27    /// Create a new tree node with the given label.
28    pub fn new(label: impl Into<String>) -> Self {
29        Self {
30            label: label.into(),
31            children: Vec::new(),
32            expanded: false,
33        }
34    }
35
36    /// Add a child node and return self for chaining.
37    pub fn child(mut self, node: TreeNode) -> Self {
38        self.children.push(node);
39        self
40    }
41}
42
43/// A tree view component with collapsible nodes and keyboard navigation.
44///
45/// Renders with tree-drawing characters. The selected node is highlighted
46/// with the theme's accent color and bold.
47pub struct TreeView {
48    nodes: Vec<TreeNode>,
49    selected: Vec<usize>,
50    focused: bool,
51}
52
53impl TreeView {
54    /// Create a new tree view with the given root nodes.
55    ///
56    /// Defaults to selecting the first root node (if any).
57    pub fn new(nodes: Vec<TreeNode>) -> Self {
58        let selected = if nodes.is_empty() {
59            Vec::new()
60        } else {
61            vec![0]
62        };
63        Self {
64            nodes,
65            selected,
66            focused: false,
67        }
68    }
69
70    /// Flatten the visible tree into a list of entries with depth, node
71    /// reference, path, and whether the node is the last child of its parent.
72    fn flatten(&self) -> Vec<(usize, &TreeNode, Vec<usize>, bool)> {
73        let mut result = Vec::new();
74        let len = self.nodes.len();
75        for (i, node) in self.nodes.iter().enumerate() {
76            Self::flatten_node(node, 0, vec![i], i == len - 1, &mut result);
77        }
78        result
79    }
80
81    fn flatten_node<'a>(
82        node: &'a TreeNode,
83        depth: usize,
84        path: Vec<usize>,
85        is_last: bool,
86        result: &mut Vec<(usize, &'a TreeNode, Vec<usize>, bool)>,
87    ) {
88        result.push((depth, node, path.clone(), is_last));
89        if node.expanded {
90            let child_len = node.children.len();
91            for (i, child) in node.children.iter().enumerate() {
92                let mut child_path = path.clone();
93                child_path.push(i);
94                Self::flatten_node(child, depth + 1, child_path, i == child_len - 1, result);
95            }
96        }
97    }
98
99    /// Find the flat index of the currently selected node by comparing paths.
100    fn selected_flat_index(&self, flat: &[(usize, &TreeNode, Vec<usize>, bool)]) -> Option<usize> {
101        flat.iter()
102            .position(|(_, _, path, _)| path == &self.selected)
103    }
104
105    /// Navigate to the node at the given mutable path.
106    fn node_at_path_mut(&mut self, path: &[usize]) -> Option<&mut TreeNode> {
107        if path.is_empty() {
108            return None;
109        }
110        let mut node = match self.nodes.get_mut(path[0]) {
111            | Some(n) => n,
112            | None => return None,
113        };
114        for &index in &path[1..] {
115            node = match node.children.get_mut(index) {
116                | Some(n) => n,
117                | None => return None,
118            };
119        }
120        Some(node)
121    }
122
123    fn navigate_down(&mut self) {
124        let new_path = {
125            let flat = self.flatten();
126            if let Some(idx) = self.selected_flat_index(&flat) {
127                flat.get(idx + 1).map(|entry| entry.2.clone())
128            } else {
129                None
130            }
131        };
132        if let Some(path) = new_path {
133            self.selected = path;
134        }
135    }
136
137    fn navigate_up(&mut self) {
138        let new_path = {
139            let flat = self.flatten();
140            if let Some(idx) = self.selected_flat_index(&flat) {
141                if idx > 0 {
142                    flat.get(idx - 1).map(|entry| entry.2.clone())
143                } else {
144                    None
145                }
146            } else {
147                None
148            }
149        };
150        if let Some(path) = new_path {
151            self.selected = path;
152        }
153    }
154}
155
156impl Focusable for TreeView {
157    fn focused(&self) -> bool {
158        self.focused
159    }
160
161    fn set_focused(&mut self, focused: bool) {
162        self.focused = focused;
163    }
164}
165
166impl Component for TreeView {
167    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
168        let theme = Theme::palette();
169        let accent_style = Style::new().fg(theme.accent()).bold();
170        let normal_style = Style::new().fg(theme.text());
171
172        let flat = self.flatten();
173        let selected_index = self.selected_flat_index(&flat);
174
175        let mut lines = Vec::new();
176        for (flat_i, (depth, node, _path, is_last)) in flat.iter().enumerate() {
177            let is_selected = selected_index == Some(flat_i);
178
179            let mut line = String::new();
180            if *depth == 0 {
181                if is_selected && self.focused {
182                    line.push_str("> ");
183                } else {
184                    line.push_str("  ");
185                }
186            } else {
187                line.push_str(&"  ".repeat(*depth));
188                if *is_last {
189                    line.push_str("└─ ");
190                } else {
191                    line.push_str("├─ ");
192                }
193            }
194
195            if !node.children.is_empty() {
196                if node.expanded {
197                    line.push_str("▼ ");
198                } else {
199                    line.push_str("▶ ");
200                }
201            } else {
202                line.push_str("  ");
203            }
204
205            line.push_str(&node.label);
206
207            let truncated = crate::utils::truncate_to_width(&line, width, "…");
208            let styled = if is_selected {
209                stylize(&truncated, &accent_style)
210            } else {
211                stylize(&truncated, &normal_style)
212            };
213            lines.push(styled);
214        }
215
216        Ok(Rendered {
217            lines,
218            cursor: None,
219            images: Vec::new(),
220        })
221    }
222
223    fn handle_input(&mut self, event: &Event) -> InputResult {
224        use crossterm::event::KeyModifiers;
225        if self.nodes.is_empty() {
226            return InputResult::Ignored;
227        }
228
229        if let Event::Key(key) = event {
230            match key.code {
231                | KeyCode::Down => {
232                    self.navigate_down();
233                    InputResult::Handled
234                },
235                | KeyCode::Up => {
236                    self.navigate_up();
237                    InputResult::Handled
238                },
239                | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
240                    self.navigate_down();
241                    InputResult::Handled
242                },
243                | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
244                    self.navigate_up();
245                    InputResult::Handled
246                },
247                | KeyCode::Right | KeyCode::Enter => {
248                    let path = self.selected.clone();
249                    if let Some(node) = self.node_at_path_mut(&path) &&
250                        !node.children.is_empty()
251                    {
252                        node.expanded = !node.expanded;
253                        return InputResult::Handled;
254                    }
255                    InputResult::Ignored
256                },
257                | KeyCode::Left => {
258                    let path = self.selected.clone();
259                    if let Some(node) = self.node_at_path_mut(&path) &&
260                        node.expanded &&
261                        !node.children.is_empty()
262                    {
263                        node.expanded = false;
264                        return InputResult::Handled;
265                    }
266                    if self.selected.len() > 1 {
267                        self.selected.pop();
268                        return InputResult::Handled;
269                    }
270                    InputResult::Ignored
271                },
272                | _ => InputResult::Ignored,
273            }
274        } else {
275            InputResult::Ignored
276        }
277    }
278
279    fn as_focusable(&self) -> Option<&dyn Focusable> {
280        Some(self)
281    }
282
283    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
284        Some(self)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use crossterm::event::KeyCode;
291
292    use super::*;
293
294    #[test]
295    fn tree_node_builder() {
296        let node = TreeNode::new("root").child(TreeNode::new("child"));
297        assert_eq!(node.label, "root");
298        assert_eq!(node.children.len(), 1);
299    }
300
301    #[test]
302    fn tree_view_new() {
303        let view = TreeView::new(vec![TreeNode::new("a"), TreeNode::new("b")]);
304        assert_eq!(view.selected, vec![0]);
305    }
306
307    #[test]
308    fn tree_view_new_empty() {
309        let view = TreeView::new(Vec::new());
310        assert!(view.selected.is_empty());
311    }
312
313    #[test]
314    fn tree_view_focusable() {
315        let mut view = TreeView::new(vec![TreeNode::new("a")]);
316        assert!(!view.focused());
317        view.set_focused(true);
318        assert!(view.focused());
319    }
320
321    #[test]
322    fn tree_view_render() {
323        Theme::with(Theme::Light, || {
324            let view = TreeView::new(vec![TreeNode::new("root")]);
325            let rendered = view.render(80).unwrap();
326            assert_eq!(rendered.lines.len(), 1);
327            assert!(rendered.lines[0].contains("root"));
328        });
329    }
330
331    #[test]
332    fn tree_view_navigation_down() {
333        let mut view = TreeView::new(vec![TreeNode::new("a"), TreeNode::new("b")]);
334        view.set_focused(true);
335        view.handle_input(&Event::Key(KeyCode::Down.into()));
336        assert_eq!(view.selected, vec![1]);
337    }
338
339    #[test]
340    fn tree_view_navigation_up() {
341        let mut view = TreeView::new(vec![TreeNode::new("a"), TreeNode::new("b")]);
342        view.set_focused(true);
343        view.selected = vec![1];
344        view.handle_input(&Event::Key(KeyCode::Up.into()));
345        assert_eq!(view.selected, vec![0]);
346    }
347
348    #[test]
349    fn tree_view_toggle_expansion() {
350        let mut view = TreeView::new(vec![TreeNode::new("root").child(TreeNode::new("child"))]);
351        view.set_focused(true);
352        let flat = view.flatten();
353        assert_eq!(flat.len(), 1);
354
355        view.handle_input(&Event::Key(KeyCode::Right.into()));
356        let flat = view.flatten();
357        assert_eq!(flat.len(), 2);
358
359        view.handle_input(&Event::Key(KeyCode::Right.into()));
360        let flat = view.flatten();
361        assert_eq!(flat.len(), 1);
362    }
363
364    #[test]
365    fn tree_view_left_navigates_to_parent() {
366        let mut view = TreeView::new(vec![TreeNode::new("root").child(TreeNode::new("child"))]);
367        view.set_focused(true);
368        view.handle_input(&Event::Key(KeyCode::Right.into()));
369        view.handle_input(&Event::Key(KeyCode::Down.into()));
370        assert_eq!(view.selected, vec![0, 0]);
371
372        view.handle_input(&Event::Key(KeyCode::Left.into()));
373        assert_eq!(view.selected, vec![0]);
374    }
375
376    #[test]
377    fn tree_view_left_collapses() {
378        let mut view = TreeView::new(vec![TreeNode::new("root").child(TreeNode::new("child"))]);
379        view.set_focused(true);
380        view.handle_input(&Event::Key(KeyCode::Right.into()));
381        assert!(view.nodes[0].expanded);
382
383        view.handle_input(&Event::Key(KeyCode::Left.into()));
384        assert!(!view.nodes[0].expanded);
385    }
386
387    #[test]
388    fn tree_view_j_k_navigation() {
389        let mut view = TreeView::new(vec![TreeNode::new("a"), TreeNode::new("b")]);
390        view.set_focused(true);
391        view.handle_input(&Event::Key(KeyCode::Char('j').into()));
392        assert_eq!(view.selected, vec![1]);
393        view.handle_input(&Event::Key(KeyCode::Char('k').into()));
394        assert_eq!(view.selected, vec![0]);
395    }
396
397    #[test]
398    fn tree_view_enter_toggles() {
399        let mut view = TreeView::new(vec![TreeNode::new("root").child(TreeNode::new("child"))]);
400        view.set_focused(true);
401        let result = view.handle_input(&Event::Key(KeyCode::Enter.into()));
402        assert_eq!(result, InputResult::Handled);
403        assert!(view.nodes[0].expanded);
404    }
405
406    #[test]
407    fn tree_view_leaf_ignores_right() {
408        let mut view = TreeView::new(vec![TreeNode::new("leaf")]);
409        view.set_focused(true);
410        let result = view.handle_input(&Event::Key(KeyCode::Right.into()));
411        assert_eq!(result, InputResult::Ignored);
412    }
413
414    #[test]
415    fn tree_view_root_left_ignored_when_collapsed() {
416        let mut view = TreeView::new(vec![TreeNode::new("root").child(TreeNode::new("child"))]);
417        view.set_focused(true);
418        let result = view.handle_input(&Event::Key(KeyCode::Left.into()));
419        assert_eq!(result, InputResult::Ignored);
420    }
421
422    #[test]
423    fn tree_view_empty_ignores_input() {
424        let mut view = TreeView::new(Vec::new());
425        let result = view.handle_input(&Event::Key(KeyCode::Down.into()));
426        assert_eq!(result, InputResult::Ignored);
427    }
428
429    #[test]
430    fn tree_view_ignores_unmapped_keys_and_non_key_events() {
431        let mut view = TreeView::new(vec![TreeNode::new("a")]);
432        let result = view.handle_input(&Event::Key(KeyCode::Char('x').into()));
433        assert_eq!(result, InputResult::Ignored);
434        let result = view.handle_input(&Event::Resize(80, 24));
435        assert_eq!(result, InputResult::Ignored);
436    }
437
438    #[test]
439    fn tree_view_focusable_trait_objects() {
440        let view = TreeView::new(vec![TreeNode::new("a")]);
441        assert!(view.as_focusable().is_some());
442        let mut view = TreeView::new(vec![TreeNode::new("a")]);
443        assert!(view.as_focusable_mut().is_some());
444    }
445
446    #[test]
447    fn tree_view_render_last_child_glyphs() {
448        Theme::with(Theme::Light, || {
449            let view = TreeView::new(vec![
450                TreeNode::new("root")
451                    .child(TreeNode::new("first"))
452                    .child(TreeNode::new("last")),
453            ]);
454            let mut view = view;
455            view.set_focused(true);
456            view.handle_input(&Event::Key(KeyCode::Right.into()));
457            let rendered = view.render(80).unwrap();
458            assert_eq!(rendered.lines.len(), 3);
459            assert!(rendered.lines[2].contains("└─"));
460        });
461    }
462
463    #[test]
464    fn tree_view_node_at_path_mut_invalid_paths() {
465        let mut view = TreeView::new(vec![TreeNode::new("root").child(TreeNode::new("child"))]);
466        assert!(view.node_at_path_mut(&[]).is_none());
467        assert!(view.node_at_path_mut(&[5]).is_none());
468        assert!(view.node_at_path_mut(&[0, 5]).is_none());
469    }
470
471    #[test]
472    fn tree_view_navigation_clamps() {
473        let mut view = TreeView::new(vec![TreeNode::new("a")]);
474        view.set_focused(true);
475        view.handle_input(&Event::Key(KeyCode::Up.into()));
476        assert_eq!(view.selected, vec![0]);
477        view.handle_input(&Event::Key(KeyCode::Down.into()));
478        assert_eq!(view.selected, vec![0]);
479    }
480}