Skip to main content

hefesto_widgets/tree/
mod.rs

1use std::collections::HashSet;
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    style::Style,
7    text::{Line, Span},
8    widgets::ListItem,
9};
10
11use crate::{DEFAULT_HIGHLIGHT, HIGHLIGHT_SYMBOL, ScrollList, ScrollListState};
12
13/// A node in a tree. Children are recursively nested.
14/// `id` must be unique across the whole tree for expand/collapse tracking.
15#[derive(Clone)]
16pub struct TreeNode<'a> {
17    pub text: Line<'a>,
18    pub children: Vec<TreeNode<'a>>,
19    pub id: usize,
20}
21
22/// Tree widget with expandable/collapsible branches.
23///
24/// Internally projects the tree into a flat `ScrollList` with indentation
25/// and expand/collapse icons. The caller manages `TreeState::expanded`.
26#[derive(Clone)]
27pub struct Tree<'a> {
28    nodes: Vec<TreeNode<'a>>,
29    indent: u16,
30    expand_icon: String,
31    collapse_icon: String,
32    leaf_icon: String,
33    highlight_style: Style,
34    highlight_symbol: String,
35    borders: ratatui::widgets::Borders,
36    border_style: Style,
37}
38
39impl<'a> Tree<'a> {
40    pub fn new(nodes: Vec<TreeNode<'a>>) -> Self {
41        Self {
42            nodes,
43            indent: 2,
44            expand_icon: "▶ ".to_string(),
45            collapse_icon: "▼ ".to_string(),
46            leaf_icon: "  ".to_string(),
47            highlight_style: DEFAULT_HIGHLIGHT,
48            highlight_symbol: HIGHLIGHT_SYMBOL.to_string(),
49            borders: ratatui::widgets::Borders::NONE,
50            border_style: Style::new(),
51        }
52    }
53
54    pub fn indent(mut self, n: u16) -> Self {
55        self.indent = n;
56        self
57    }
58
59    pub fn expand_icon(mut self, icon: &str) -> Self {
60        self.expand_icon = icon.to_string();
61        self
62    }
63
64    pub fn collapse_icon(mut self, icon: &str) -> Self {
65        self.collapse_icon = icon.to_string();
66        self
67    }
68
69    pub fn leaf_icon(mut self, icon: &str) -> Self {
70        self.leaf_icon = icon.to_string();
71        self
72    }
73
74    pub fn highlight_style(mut self, style: Style) -> Self {
75        self.highlight_style = style;
76        self
77    }
78
79    pub fn highlight_symbol(mut self, symbol: &str) -> Self {
80        self.highlight_symbol = symbol.to_string();
81        self
82    }
83
84    pub fn borders(mut self, borders: ratatui::widgets::Borders) -> Self {
85        self.borders = borders;
86        self
87    }
88
89    pub fn border_style(mut self, style: Style) -> Self {
90        self.border_style = style;
91        self
92    }
93
94    /// Count of visible items in the projected flat view.
95    pub fn visible_count(&self, expanded: &HashSet<usize>) -> usize {
96        let mut count = 0;
97        Self::count_visible(&self.nodes, expanded, &mut count);
98        count
99    }
100
101    fn count_visible(nodes: &[TreeNode], expanded: &HashSet<usize>, count: &mut usize) {
102        for node in nodes {
103            *count += 1;
104            if !node.children.is_empty() && expanded.contains(&node.id) {
105                Self::count_visible(&node.children, expanded, count);
106            }
107        }
108    }
109
110    /// Flatten the tree into visible `ListItem`s based on `expanded` set.
111    fn project(&self, expanded: &HashSet<usize>) -> Vec<ListItem<'a>> {
112        let mut items = Vec::new();
113        for node in &self.nodes {
114            Self::project_node(node, 0, self.indent, expanded, &self.expand_icon, &self.collapse_icon, &self.leaf_icon, &mut items);
115        }
116        items
117    }
118
119    fn project_node(
120        node: &TreeNode<'a>,
121        depth: u16,
122        indent: u16,
123        expanded: &HashSet<usize>,
124        expand_icon: &str,
125        collapse_icon: &str,
126        leaf_icon: &str,
127        items: &mut Vec<ListItem<'a>>,
128    ) {
129        let indent_str = " ".repeat((depth as usize) * indent as usize);
130        let icon = if node.children.is_empty() {
131            leaf_icon
132        } else if expanded.contains(&node.id) {
133            collapse_icon
134        } else {
135            expand_icon
136        };
137
138        let mut line = node.text.clone();
139        let prefix = format!("{}{}", indent_str, icon);
140        line.spans.insert(0, Span::raw(prefix));
141
142        items.push(ListItem::new(line));
143
144        if expanded.contains(&node.id) {
145            for child in &node.children {
146                Self::project_node(child, depth + 1, indent, expanded, expand_icon, collapse_icon, leaf_icon, items);
147            }
148        }
149    }
150}
151
152/// State for the Tree widget.
153#[derive(Clone, Default)]
154pub struct TreeState {
155    /// Set of node ids that are currently expanded.
156    pub expanded: HashSet<usize>,
157    pub scroll_state: ScrollListState,
158}
159
160impl TreeState {
161    pub fn toggle(&mut self, id: usize) {
162        if self.expanded.contains(&id) {
163            self.expanded.remove(&id);
164        } else {
165            self.expanded.insert(id);
166        }
167    }
168
169    /// Expand all nodes that have children.
170    pub fn expand_all(&mut self, nodes: &[TreeNode]) {
171        for node in nodes {
172            if !node.children.is_empty() {
173                self.expanded.insert(node.id);
174                self.expand_all(&node.children);
175            }
176        }
177    }
178
179    /// Expand this node and all its descendants recursively.
180    pub fn expand_subtree(&mut self, nodes: &[TreeNode], id: usize) {
181        if let Some(node) = Self::find_node(nodes, id) {
182            self.expanded.insert(id);
183            for child in &node.children {
184                self.expand_subtree(nodes, child.id);
185            }
186        }
187    }
188
189    /// Collapse from cursor position.
190    ///
191    /// If the cursor is on an expanded node with children → collapse that node.
192    /// Otherwise find the nearest expanded ancestor and collapse it.
193    pub fn collapse_at(&mut self, nodes: &[TreeNode], cursor_id: usize) {
194        let is_expanded = self.expanded.contains(&cursor_id);
195        let has_children = Self::find_node(nodes, cursor_id)
196            .map(|n| !n.children.is_empty())
197            .unwrap_or(false);
198
199        if is_expanded && has_children {
200            self.expanded.remove(&cursor_id);
201            return;
202        }
203
204        if let Some(ancestor) = self.nearest_expanded_ancestor(nodes, cursor_id) {
205            self.expanded.remove(&ancestor);
206        }
207    }
208
209    /// Expand from cursor position.
210    ///
211    /// If cursor is on a collapsed node → expand just that node.
212    /// If cursor is on an already-expanded node → expand the entire subtree recursively.
213    pub fn expand_at(&mut self, nodes: &[TreeNode], cursor_id: usize) {
214        if self.expanded.contains(&cursor_id) {
215            self.expand_subtree(nodes, cursor_id);
216        } else {
217            self.expanded.insert(cursor_id);
218        }
219    }
220
221    /// Find the nearest ancestor of `cursor_id` that is currently expanded,
222    /// walking from the root downward. Returns `None` if no ancestor is expanded.
223    pub fn nearest_expanded_ancestor(&self, nodes: &[TreeNode], cursor_id: usize) -> Option<usize> {
224        let path = Self::find_path(nodes, cursor_id)?;
225        let mut result = None;
226        for id in &path {
227            if *id == cursor_id {
228                break;
229            }
230            if self.expanded.contains(id) {
231                result = Some(*id);
232            }
233        }
234        result
235    }
236
237    // ── private helpers ──
238
239    fn find_node<'b>(nodes: &'b [TreeNode<'b>], target_id: usize) -> Option<&'b TreeNode<'b>> {
240        for node in nodes {
241            if node.id == target_id {
242                return Some(node);
243            }
244            if let Some(found) = Self::find_node(&node.children, target_id) {
245                return Some(found);
246            }
247        }
248        None
249    }
250
251    fn find_path<'b>(nodes: &'b [TreeNode<'b>], target_id: usize) -> Option<Vec<usize>> {
252        for node in nodes {
253            if node.id == target_id {
254                return Some(vec![node.id]);
255            }
256            if let Some(mut path) = Self::find_path(&node.children, target_id) {
257                path.insert(0, node.id);
258                return Some(path);
259            }
260        }
261        None
262    }
263
264    /// Return the visible item index of the first node with `id`,
265    /// or `None` if it's hidden behind a collapsed ancestor.
266    pub fn visible_index_of(&self, nodes: &[TreeNode], target_id: usize) -> Option<usize> {
267        let mut idx = 0usize;
268        Self::walk(nodes, target_id, &self.expanded, true, &mut idx)
269    }
270    pub fn walk<'b>(nodes: &'b [TreeNode<'b>], target_id: usize, expanded: &HashSet<usize>, visible: bool, idx: &mut usize) -> Option<usize> {
271        for node in nodes {
272            if visible {
273                if node.id == target_id {
274                    return Some(*idx);
275                }
276                *idx += 1;
277            }
278
279            if !node.children.is_empty() && expanded.contains(&node.id) {
280                if let Some(found) = Self::walk(&node.children, target_id, expanded, visible, idx) {
281                    return Some(found);
282                }
283            }
284        }
285        None
286    }
287}
288
289impl<'a> ratatui::widgets::StatefulWidget for Tree<'a> {
290    type State = TreeState;
291
292    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
293        let items = self.project(&state.expanded);
294        ScrollList::new_list(items)
295            .highlight_style(self.highlight_style)
296            .highlight_symbol(&self.highlight_symbol)
297            .borders(self.borders)
298            .border_style(self.border_style)
299            .render(area, buf, &mut state.scroll_state);
300    }
301}
302#[cfg(test)]
303mod tests;