Skip to main content

lv_tui/widgets/
tree.rs

1use crate::component::{Component, EventCx};
2use crate::event::Event;
3use crate::geom::{Pos, Rect, Size};
4use crate::render::RenderCx;
5use crate::style::Style;
6use crate::text::Text;
7
8/// A node in a [`Tree`] widget.
9pub struct TreeNode {
10    pub label: Text,
11    pub children: Vec<TreeNode>,
12    pub expanded: bool,
13}
14
15/// Flattened visible entry in a tree.
16struct VisibleEntry {
17    /// Index into the original tree's flat list (for selection tracking).
18    node_path: Vec<usize>, // path of indices from root: [0, 2] = nodes[0].children[2]
19    depth: usize,
20    is_last: bool,
21    has_children: bool,
22    expanded: bool,
23    label: Text,
24}
25
26/// A hierarchical tree view widget.
27///
28/// Supports expand/collapse via Enter/Space, keyboard navigation, and optional
29/// guide lines.
30pub struct Tree {
31    nodes: Vec<TreeNode>,
32    /// Index into the flattened visible list.
33    selected: usize,
34    show_guides: bool,
35    scroll_offset: usize,
36    rect: Rect,
37    style: Style,
38    select_style: Style,
39}
40
41impl Tree {
42    /// Creates a new tree with the given root nodes.
43    pub fn new(nodes: Vec<TreeNode>) -> Self {
44        Self {
45            nodes,
46            selected: 0,
47            show_guides: true,
48            scroll_offset: 0,
49            rect: Rect::default(),
50            style: Style::default(),
51            select_style: Style::default(),
52        }
53    }
54
55    pub fn show_guides(mut self, show: bool) -> Self {
56        self.show_guides = show;
57        self
58    }
59
60    pub fn style(mut self, style: Style) -> Self {
61        self.style = style;
62        self
63    }
64
65    pub fn select_style(mut self, style: Style) -> Self {
66        self.select_style = style;
67        self
68    }
69
70    pub fn selected_index(&self) -> usize {
71        self.selected
72    }
73
74    /// Flattens the tree into a list of visible entries based on expansion state.
75    fn flatten(&self) -> Vec<VisibleEntry> {
76        let mut entries = Vec::new();
77        for (i, node) in self.nodes.iter().enumerate() {
78            self.flatten_node(node, &vec![i], 0, i == self.nodes.len() - 1, &mut entries);
79        }
80        entries
81    }
82
83    fn flatten_node(
84        &self,
85        node: &TreeNode,
86        path: &[usize],
87        depth: usize,
88        is_last: bool,
89        entries: &mut Vec<VisibleEntry>,
90    ) {
91        let has_children = !node.children.is_empty();
92        entries.push(VisibleEntry {
93            node_path: path.to_vec(),
94            depth,
95            is_last,
96            has_children,
97            expanded: node.expanded,
98            label: node.label.clone(),
99        });
100
101        if node.expanded {
102            let child_count = node.children.len();
103            for (i, child) in node.children.iter().enumerate() {
104                let mut child_path = path.to_vec();
105                child_path.push(i);
106                self.flatten_node(
107                    child,
108                    &child_path,
109                    depth + 1,
110                    i == child_count - 1,
111                    entries,
112                );
113            }
114        }
115    }
116
117    /// Finds the node at the given path.
118    fn node_at_path(&mut self, path: &[usize]) -> Option<&mut TreeNode> {
119        if path.is_empty() {
120            return None;
121        }
122        let mut node = self.nodes.get_mut(path[0])?;
123        for &idx in &path[1..] {
124            node = node.children.get_mut(idx)?;
125        }
126        Some(node)
127    }
128}
129
130impl Component for Tree {
131    fn render(&self, cx: &mut RenderCx) {
132        let entries = self.flatten();
133        if entries.is_empty() {
134            return;
135        }
136
137        let visible_height = self.rect.height.max(1) as usize;
138        let start = self.scroll_offset.min(entries.len().saturating_sub(1));
139        let end = (start + visible_height).min(entries.len());
140
141        for i in start..end {
142            let entry = &entries[i];
143            let is_selected = i == self.selected;
144            let row_y = self.rect.y + (i - start) as u16;
145
146            // Build prefix: indentation + guides + icon
147            let mut prefix = String::new();
148
149            if self.show_guides {
150                // Build guide lines for ancestors
151                if entry.depth > 0 {
152                    // Determine which ancestor levels have continuation
153                    let mut has_continuation = vec![false; entry.depth];
154                    for a in 0..entry.depth {
155                        // Check if there are more entries after this one at the same or deeper depth
156                        // that belong to the same ancestor
157                        for j in (i + 1)..entries.len() {
158                            if entries[j].depth < a + 1 {
159                                break;
160                            }
161                            if entries[j].depth == a + 1 {
162                                has_continuation[a] = true;
163                                break;
164                            }
165                        }
166                    }
167
168                    for a in 0..entry.depth {
169                        if has_continuation[a] {
170                            prefix.push_str("│ ");
171                        } else {
172                            prefix.push_str("  ");
173                        }
174                    }
175                }
176
177                // Branch character for the current entry
178                if entry.depth > 0 {
179                    if entry.is_last {
180                        prefix.push_str("└─");
181                    } else {
182                        prefix.push_str("├─");
183                    }
184                }
185            } else {
186                for _ in 0..entry.depth {
187                    prefix.push_str("  ");
188                }
189            }
190
191            // Icon
192            if entry.has_children {
193                if entry.expanded {
194                    prefix.push_str("▼ ");
195                } else {
196                    prefix.push_str("▶ ");
197                }
198            } else {
199                prefix.push_str("  ");
200            }
201
202            // Render
203            let style = if is_selected {
204                &self.select_style
205            } else {
206                &self.style
207            };
208
209            cx.buffer.write_text(
210                Pos {
211                    x: self.rect.x,
212                    y: row_y,
213                },
214                self.rect,
215                &prefix,
216                style,
217            );
218
219            let label_text = entry.label.first_text();
220            let prefix_w = crate::widgets::textarea::str_width(&prefix);
221            cx.buffer.write_text(
222                Pos {
223                    x: self.rect.x + prefix_w,
224                    y: row_y,
225                },
226                self.rect,
227                label_text,
228                style,
229            );
230        }
231    }
232
233    fn measure(
234        &self,
235        _constraint: crate::layout::Constraint,
236        _cx: &mut crate::component::MeasureCx,
237    ) -> Size {
238        let entries = self.flatten();
239        let max_w = entries
240            .iter()
241            .map(|e| (e.depth * 2 + 2) as u16 + e.label.max_width())
242            .max()
243            .unwrap_or(0);
244        Size {
245            width: max_w,
246            height: entries.len().max(1) as u16,
247        }
248    }
249
250    fn event(&mut self, event: &Event, cx: &mut EventCx) {
251        if matches!(event, Event::Focus | Event::Blur | Event::Tick) {
252            return;
253        }
254
255        let entries = self.flatten();
256        if entries.is_empty() {
257            return;
258        }
259
260        if let Event::Key(key_event) = event {
261            match &key_event.key {
262                crate::event::Key::Up => {
263                    if self.selected > 0 {
264                        self.selected -= 1;
265                        self.scroll_to_selected(entries.len());
266                        cx.invalidate_paint();
267                    }
268                }
269                crate::event::Key::Down => {
270                    if self.selected + 1 < entries.len() {
271                        self.selected += 1;
272                        self.scroll_to_selected(entries.len());
273                        cx.invalidate_paint();
274                    }
275                }
276                crate::event::Key::Enter | crate::event::Key::Char(' ') => {
277                    self.toggle_selected();
278                    cx.invalidate_paint();
279                }
280                crate::event::Key::Right => {
281                    self.expand_selected();
282                    cx.invalidate_paint();
283                }
284                crate::event::Key::Left => {
285                    self.collapse_or_parent(&entries);
286                    cx.invalidate_paint();
287                }
288                crate::event::Key::Home => {
289                    self.selected = 0;
290                    self.scroll_offset = 0;
291                    cx.invalidate_paint();
292                }
293                crate::event::Key::End => {
294                    self.selected = entries.len().saturating_sub(1);
295                    self.scroll_to_selected(entries.len());
296                    cx.invalidate_paint();
297                }
298                _ => {}
299            }
300        }
301    }
302
303    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) {
304        self.rect = rect;
305    }
306
307    fn focusable(&self) -> bool {
308        false
309    }
310
311    fn style(&self) -> Style {
312        self.style.clone()
313    }
314}
315
316impl Tree {
317    fn toggle_selected(&mut self) {
318        let entries = self.flatten();
319        if let Some(entry) = entries.get(self.selected) {
320            if entry.has_children {
321                if let Some(node) = self.node_at_path(&entry.node_path) {
322                    node.expanded = !node.expanded;
323                }
324            }
325        }
326    }
327
328    fn expand_selected(&mut self) {
329        let entries = self.flatten();
330        if let Some(entry) = entries.get(self.selected) {
331            if entry.has_children && !entry.expanded {
332                if let Some(node) = self.node_at_path(&entry.node_path) {
333                    node.expanded = true;
334                }
335            }
336        }
337    }
338
339    fn collapse_or_parent(&mut self, entries: &[VisibleEntry]) {
340        if let Some(entry) = entries.get(self.selected) {
341            if entry.has_children && entry.expanded {
342                // Collapse current node
343                if let Some(node) = self.node_at_path(&entry.node_path) {
344                    node.expanded = false;
345                }
346            } else if entry.depth > 0 {
347                // Jump to parent's position
348                let parent_depth = entry.depth - 1;
349                for (i, e) in entries.iter().enumerate() {
350                    if e.node_path.len() == entry.node_path.len() - 1
351                        && e.depth == parent_depth
352                    {
353                        self.selected = i;
354                        break;
355                    }
356                }
357            }
358        }
359    }
360
361    fn scroll_to_selected(&mut self, total_visible: usize) {
362        let visible_height = self.rect.height.max(1) as usize;
363        if self.selected < self.scroll_offset {
364            self.scroll_offset = self.selected;
365        } else if self.selected >= self.scroll_offset + visible_height {
366            self.scroll_offset = self.selected.saturating_sub(visible_height.saturating_sub(1));
367        }
368        self.scroll_offset = self.scroll_offset.min(
369            total_visible.saturating_sub(visible_height),
370        );
371    }
372}