Skip to main content

tui_kit/
tree.rs

1//! Foldable tree widget — a hierarchical, scrollable, focusable list.
2//!
3//! Like [`crate::list`], the widget itself is stateless about your data: the
4//! caller owns the tree and its expand/collapse state, flattens the **visible**
5//! nodes into a `Vec<TreeRow>` each frame (skipping children of collapsed
6//! branches), and passes that slice in. [`TreeState`] only tracks selection and
7//! scroll offset over those visible rows.
8//!
9//! ```text
10//! ┌─ Targets ──────────────────────────────────────────────────┐
11//! │ ▾ services/api/Makefile                                     │
12//! │     build    Build the api                                  │
13//! │   ▶ run      Run locally                          PORT       │
14//! │ ▸ web/build.mk                              (collapsed)      │
15//! └─────────────────────────────────────────────────────────────┘
16//! ```
17//!
18//! ## Usage
19//!
20//! ```ignore
21//! // 1. Flatten your tree to visible rows (respecting collapsed branches):
22//! let rows: Vec<TreeRow> = visible_nodes.iter().map(|n| {
23//!     if n.is_branch {
24//!         TreeRow::branch(n.depth, n.label.clone(), n.expanded)
25//!     } else {
26//!         TreeRow::leaf(n.depth, n.label.clone())
27//!     }
28//! }).collect();
29//!
30//! // 2. Render, and map state.selected back to your node on Enter.
31//! render_tree(f, area, "Targets", None, &rows, &mut tree_state, focused, &theme);
32//! ```
33
34use ratatui::{
35    layout::{Alignment, Constraint, Direction, Layout, Rect},
36    style::Style,
37    text::{Line, Span},
38    widgets::Paragraph,
39    Frame,
40};
41
42use crate::{
43    block::{focusable_block, render_scrollbar},
44    Theme,
45};
46
47/// Selection + scroll state for a [`render_tree`] widget, over the slice of
48/// currently-visible rows.
49pub struct TreeState {
50    /// Index of the selected row within the visible rows.
51    pub selected: usize,
52    offset: usize,
53}
54
55impl TreeState {
56    pub fn new() -> Self {
57        Self { selected: 0, offset: 0 }
58    }
59
60    /// Move selection to the next visible row, wrapping at the end.
61    pub fn select_next(&mut self, row_count: usize) {
62        if row_count == 0 {
63            return;
64        }
65        self.selected = (self.selected + 1) % row_count;
66    }
67
68    /// Move selection to the previous visible row, wrapping at the start.
69    pub fn select_prev(&mut self, row_count: usize) {
70        if row_count == 0 {
71            return;
72        }
73        if self.selected == 0 {
74            self.selected = row_count - 1;
75        } else {
76            self.selected -= 1;
77        }
78    }
79
80    pub fn selected(&self) -> usize {
81        self.selected
82    }
83
84    pub fn offset(&self) -> usize {
85        self.offset
86    }
87
88    /// Clamp the scroll offset so the selected row stays visible.
89    fn clamp_offset(&mut self, visible_height: usize) {
90        if visible_height == 0 {
91            return;
92        }
93        if self.selected < self.offset {
94            self.offset = self.selected;
95        } else if self.selected >= self.offset + visible_height {
96            self.offset = self.selected - visible_height + 1;
97        }
98    }
99}
100
101impl Default for TreeState {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107/// One visible row of a tree.
108pub struct TreeRow {
109    /// Indentation level (0 = root). Each level adds two columns.
110    pub depth: u16,
111    /// Primary label shown after the fold glyph.
112    pub label: String,
113    /// Optional dimmed text shown right-aligned (e.g. a hint or count).
114    pub secondary: Option<String>,
115    /// `None` = leaf node; `Some(true)` = expanded branch; `Some(false)` =
116    /// collapsed branch. Controls the ▾ / ▸ glyph.
117    pub expanded: Option<bool>,
118    /// Optional label style override (e.g. accent for directories). Defaults to
119    /// [`Theme::body`] when `None`.
120    pub style: Option<Style>,
121}
122
123impl TreeRow {
124    /// A leaf row (no fold glyph).
125    pub fn leaf(depth: u16, label: impl Into<String>) -> Self {
126        Self {
127            depth,
128            label: label.into(),
129            secondary: None,
130            expanded: None,
131            style: None,
132        }
133    }
134
135    /// A branch row that can be expanded or collapsed.
136    pub fn branch(depth: u16, label: impl Into<String>, expanded: bool) -> Self {
137        Self {
138            depth,
139            label: label.into(),
140            secondary: None,
141            expanded: Some(expanded),
142            style: None,
143        }
144    }
145
146    pub fn secondary(mut self, secondary: impl Into<String>) -> Self {
147        self.secondary = Some(secondary.into());
148        self
149    }
150
151    pub fn style(mut self, style: Style) -> Self {
152        self.style = Some(style);
153        self
154    }
155}
156
157/// Render a scrollable, focusable tree of `rows` inside `area`.
158///
159/// - Uses [`focusable_block`] for the outer border + optional digit shortcut.
160/// - The selected row is highlighted with [`Theme::selection`].
161/// - Branch rows show a ▾ (expanded) or ▸ (collapsed) glyph; leaves are indented
162///   to align with branch labels.
163/// - `secondary` text is right-aligned using [`Theme::hint`].
164/// - Handles an empty `rows` slice without panicking.
165pub fn render_tree(
166    f: &mut Frame,
167    area: Rect,
168    title: &str,
169    shortcut: Option<u8>,
170    rows: &[TreeRow],
171    state: &mut TreeState,
172    focused: bool,
173    theme: &Theme,
174) {
175    let block = focusable_block(title, shortcut, focused, theme);
176    let inner = block.inner(area);
177    f.render_widget(block, area);
178    render_scrollbar(f, area, rows.len(), state.offset);
179
180    let visible_height = inner.height as usize;
181
182    if !rows.is_empty() && state.selected >= rows.len() {
183        state.selected = rows.len() - 1;
184    }
185    state.clamp_offset(visible_height);
186
187    if rows.is_empty() || visible_height == 0 {
188        return;
189    }
190
191    for (idx, row) in rows
192        .iter()
193        .enumerate()
194        .skip(state.offset)
195        .take(visible_height)
196    {
197        let row_y = inner.y + (idx - state.offset) as u16;
198        let row_area = Rect {
199            x: inner.x,
200            y: row_y,
201            width: inner.width,
202            height: 1,
203        };
204
205        let is_selected = idx == state.selected;
206        let label_style = if is_selected {
207            theme.selection
208        } else {
209            row.style.unwrap_or(theme.body)
210        };
211        let glyph_style = if is_selected { theme.selection } else { theme.hint };
212
213        let indent = "  ".repeat(row.depth as usize);
214        let glyph = match row.expanded {
215            Some(true) => "▾ ",
216            Some(false) => "▸ ",
217            None => "  ",
218        };
219        let prefix = format!("{indent}{glyph}");
220
221        let left = Line::from(vec![
222            Span::styled(prefix, glyph_style),
223            Span::styled(row.label.clone(), label_style),
224        ]);
225
226        match &row.secondary {
227            None => {
228                f.render_widget(Paragraph::new(left), row_area);
229            }
230            Some(sec) => {
231                let sec_width = (sec.chars().count() as u16 + 1)
232                    .min(inner.width.saturating_sub(1));
233                let prim_width = inner.width.saturating_sub(sec_width);
234
235                let chunks = Layout::default()
236                    .direction(Direction::Horizontal)
237                    .constraints([
238                        Constraint::Length(prim_width),
239                        Constraint::Length(sec_width),
240                    ])
241                    .split(row_area);
242
243                let sec_style = if is_selected { theme.selection } else { theme.hint };
244                let sec_para = Paragraph::new(Line::from(Span::styled(sec.clone(), sec_style)))
245                    .alignment(Alignment::Right);
246
247                f.render_widget(Paragraph::new(left), chunks[0]);
248                f.render_widget(sec_para, chunks[1]);
249            }
250        }
251    }
252}