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