1use 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
47pub struct TreeState {
50 pub selected: usize,
52 offset: usize,
53}
54
55impl TreeState {
56 pub fn new() -> Self {
57 Self { selected: 0, offset: 0 }
58 }
59
60 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 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 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
107pub struct TreeRow {
109 pub depth: u16,
111 pub label: String,
113 pub secondary: Option<String>,
115 pub expanded: Option<bool>,
118 pub style: Option<Style>,
121}
122
123impl TreeRow {
124 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 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
157pub 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}