tui_realm_treeview/
widget.rs

1//! # Widget
2//!
3//! This module implements the tui widget for rendering a treeview
4
5use tuirealm::ratatui::buffer::Buffer;
6use tuirealm::ratatui::layout::Rect;
7use tuirealm::ratatui::style::Style;
8use tuirealm::ratatui::widgets::{Block, StatefulWidget, Widget};
9use unicode_width::UnicodeWidthStr;
10
11use super::{Node, NodeValue, Tree, TreeState};
12
13/// tui-rs widget implementation of a [`crate::TreeView`]
14pub struct TreeWidget<'a, V: NodeValue> {
15    /// Block properties
16    block: Option<Block<'a>>,
17    /// Style for tree
18    style: Style,
19    /// Highlight style
20    highlight_style: Style,
21    /// Symbol to display on the side of the current highlighted
22    highlight_symbol: Option<&'a str>,
23    /// Spaces to use for indentation
24    indent_size: usize,
25    /// [`Tree`] to render
26    tree: &'a Tree<V>,
27}
28
29impl<'a, V: NodeValue> TreeWidget<'a, V> {
30    /// Setup a new [`TreeWidget`]
31    pub fn new(tree: &'a Tree<V>) -> Self {
32        Self {
33            block: None,
34            style: Style::default(),
35            highlight_style: Style::default(),
36            highlight_symbol: None,
37            indent_size: 4,
38            tree,
39        }
40    }
41
42    /// Set block to render around the tree view
43    pub fn block(mut self, block: Block<'a>) -> Self {
44        self.block = Some(block);
45        self
46    }
47
48    /// Set style for tree view
49    pub fn style(mut self, s: Style) -> Self {
50        self.style = s;
51        self
52    }
53
54    /// Set highlighted entry style
55    pub fn highlight_style(mut self, s: Style) -> Self {
56        self.highlight_style = s;
57        self
58    }
59
60    /// Set symbol to prepend to highlighted entry
61    pub fn highlight_symbol(mut self, s: &'a str) -> Self {
62        self.highlight_symbol = Some(s);
63        self
64    }
65
66    /// Size for indentation
67    pub fn indent_size(mut self, sz: usize) -> Self {
68        self.indent_size = sz;
69        self
70    }
71}
72
73struct Render {
74    depth: usize,
75    skip_rows: usize,
76}
77
78impl<V: NodeValue> Widget for TreeWidget<'_, V> {
79    fn render(self, area: Rect, buf: &mut Buffer) {
80        let mut state = TreeState::default();
81        StatefulWidget::render(self, area, buf, &mut state);
82    }
83}
84
85impl<V: NodeValue> StatefulWidget for TreeWidget<'_, V> {
86    type State = TreeState;
87
88    fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
89        // Set style for area
90        buf.set_style(area, self.style);
91        // Build block
92        let area = match self.block.take() {
93            Some(b) => {
94                let inner_area = b.inner(area);
95                b.render(area, buf);
96                inner_area
97            }
98            None => area,
99        };
100        // Return if too small
101        if area.width < 1 || area.height < 1 {
102            return;
103        }
104        // Recurse render
105        let mut render = Render {
106            depth: 1,
107            skip_rows: self.calc_rows_to_skip(state, area.height),
108        };
109        self.iter_nodes(self.tree.root(), area, buf, state, &mut render);
110    }
111}
112
113impl<V: NodeValue> TreeWidget<'_, V> {
114    fn iter_nodes(
115        &self,
116        node: &Node<V>,
117        mut area: Rect,
118        buf: &mut Buffer,
119        state: &TreeState,
120        render: &mut Render,
121    ) -> Rect {
122        // Render self
123        area = self.render_node(node, area, buf, state, render);
124        // Render children if node is open
125        if state.is_open(node) {
126            // Increment depth
127            render.depth += 1;
128            for child in node.iter() {
129                if area.height == 0 {
130                    break;
131                }
132                area = self.iter_nodes(child, area, buf, state, render);
133            }
134            // Decrement depth
135            render.depth -= 1;
136        }
137        area
138    }
139
140    fn render_node(
141        &self,
142        node: &Node<V>,
143        area: Rect,
144        buf: &mut Buffer,
145        state: &TreeState,
146        render: &mut Render,
147    ) -> Rect {
148        // If row should skip, then skip
149        if render.skip_rows > 0 {
150            render.skip_rows -= 1;
151            return area;
152        }
153        let highlight_symbol = match state.is_selected(node) {
154            true => Some(self.highlight_symbol.unwrap_or_default()),
155            false => None,
156        };
157        // Get area for current node
158        let node_area = Rect {
159            x: area.x,
160            y: area.y,
161            width: area.width,
162            height: 1,
163        };
164        // Get style to use
165        let style = match state.is_selected(node) {
166            false => self.style,
167            true => self.highlight_style,
168        };
169        // Apply style
170        buf.set_style(node_area, style);
171        // Calc depth for node (is selected?)
172        let indent_size = render.depth * self.indent_size;
173        let indent_size = match state.is_selected(node) {
174            true if highlight_symbol.is_some() => {
175                indent_size.saturating_sub(highlight_symbol.unwrap().width() + 1)
176            }
177            _ => indent_size,
178        };
179        let width: usize = (area.width + area.x) as usize;
180        // Write indentation
181        let (start_x, start_y) = buf.set_stringn(
182            area.x,
183            area.y,
184            " ".repeat(indent_size),
185            width - indent_size,
186            style,
187        );
188        // Write highlight symbol
189        let (start_x, start_y) = highlight_symbol
190            .map(|x| buf.set_stringn(start_x, start_y, x, width - start_x as usize, style))
191            .map(|(x, y)| buf.set_stringn(x, y, " ", width - start_x as usize, style))
192            .unwrap_or((start_x, start_y));
193
194        let mut start_x = start_x;
195        let mut start_y = start_y;
196        for (text, part_style) in node.value().render_parts_iter() {
197            let part_style = part_style.unwrap_or(style);
198            // Write node name
199            (start_x, start_y) =
200                buf.set_stringn(start_x, start_y, text, width - start_x as usize, part_style);
201        }
202        // Write arrow based on node
203        let write_after = if state.is_open(node) {
204            // Is open
205            " \u{25bc}" // Arrow down
206        } else if node.is_leaf() {
207            // Is leaf (has no children)
208            "  "
209        } else {
210            // Has children, but is closed
211            " \u{25b6}" // Arrow to right
212        };
213        let _ = buf.set_stringn(
214            start_x,
215            start_y,
216            write_after,
217            width - start_x as usize,
218            style,
219        );
220        // Return new area
221        Rect {
222            x: area.x,
223            y: area.y + 1,
224            width: area.width,
225            height: area.height - 1,
226        }
227    }
228
229    /// Calculate rows to skip before starting rendering the current tree
230    fn calc_rows_to_skip(&self, state: &TreeState, height: u16) -> usize {
231        // if no node is selected, return 0
232        let selected = match state.selected() {
233            Some(s) => s,
234            None => return 0,
235        };
236
237        /// Recursive visit each node (excluding closed ones) and calculate full size and index of selected node
238        fn visit_nodes<V: NodeValue>(
239            node: &Node<V>,
240            state: &TreeState,
241            selected: &str,
242            selected_idx: &mut usize,
243            size: &mut usize,
244        ) {
245            *size += 1;
246            if node.id().as_str() == selected {
247                *selected_idx = *size;
248            }
249
250            if !state.is_closed(node) {
251                for child in node.iter() {
252                    visit_nodes(child, state, selected, selected_idx, size);
253                }
254            }
255        }
256
257        let selected_idx: &mut usize = &mut 0;
258        let size = &mut 0;
259        visit_nodes(self.tree.root(), state, selected, selected_idx, size);
260
261        let render_area_h = height as usize;
262        let num_lines_to_show_at_top = render_area_h / 2;
263        let offset_max = (*size).saturating_sub(render_area_h);
264        (*selected_idx)
265            .saturating_sub(num_lines_to_show_at_top)
266            .min(offset_max)
267    }
268}
269
270#[cfg(test)]
271mod test {
272
273    use pretty_assertions::assert_eq;
274    use tuirealm::ratatui::Terminal;
275    use tuirealm::ratatui::backend::TestBackend;
276    use tuirealm::ratatui::layout::{Constraint, Direction as LayoutDirection, Layout};
277    use tuirealm::ratatui::style::Color;
278
279    use super::*;
280    use crate::mock::mock_tree;
281
282    #[test]
283    fn should_construct_default_widget() {
284        let tree = mock_tree();
285        let widget = TreeWidget::new(&tree);
286        assert_eq!(widget.block, None);
287        assert_eq!(widget.highlight_style, Style::default());
288        assert_eq!(widget.highlight_symbol, None);
289        assert_eq!(widget.indent_size, 4);
290        assert_eq!(widget.style, Style::default());
291    }
292
293    #[test]
294    fn should_construct_widget() {
295        let tree = mock_tree();
296        let widget = TreeWidget::new(&tree)
297            .block(Block::default())
298            .highlight_style(Style::default().fg(Color::Red))
299            .highlight_symbol(">")
300            .indent_size(8)
301            .style(Style::default().fg(Color::LightRed));
302        assert!(widget.block.is_some());
303        assert_eq!(widget.highlight_style.fg.unwrap(), Color::Red);
304        assert_eq!(widget.indent_size, 8);
305        assert_eq!(widget.highlight_symbol.unwrap(), ">");
306        assert_eq!(widget.style.fg.unwrap(), Color::LightRed);
307    }
308
309    #[test]
310    fn should_have_no_row_to_skip_when_in_first_height_elements() {
311        let tree = mock_tree();
312        let mut state = TreeState::default();
313        // Select aA2
314        let aa2 = tree.root().query(&String::from("aA2")).unwrap();
315        state.select(tree.root(), aa2);
316        // Get rows to skip (no block)
317        let widget = TreeWidget::new(&tree);
318        // Before end
319        assert_eq!(widget.calc_rows_to_skip(&state, 8), 2);
320        // At end
321        assert_eq!(widget.calc_rows_to_skip(&state, 6), 3);
322    }
323
324    #[test]
325    fn should_have_rows_to_skip_when_out_of_viewport() {
326        let tree = mock_tree();
327        let mut state = TreeState::default();
328        // Open all previous nodes
329        state.force_open(&["/", "a", "aA", "aB", "aC", "b", "bA", "bB"]);
330        // Select bB2
331        let bb2 = tree.root().query(&String::from("bB2")).unwrap();
332        state.select(tree.root(), bb2);
333        // Get rows to skip (no block)
334        let widget = TreeWidget::new(&tree);
335        // 20th element - height (12) + 1
336        assert_eq!(widget.calc_rows_to_skip(&state, 8), 17);
337    }
338
339    #[test]
340    fn should_not_panic_per_layout_direction() {
341        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
342        let tree = mock_tree();
343        let constraints = [[50, 50], [100, 0]];
344        for direction in [LayoutDirection::Vertical, LayoutDirection::Horizontal] {
345            for constraint in constraints {
346                let widget = TreeWidget::new(&tree);
347                terminal
348                    .draw(|frame| {
349                        let layout = Layout::default()
350                            .direction(direction)
351                            .constraints(Constraint::from_percentages(constraint))
352                            .split(frame.area());
353                        frame.render_widget(widget, layout[1])
354                    })
355                    .unwrap();
356            }
357        }
358    }
359
360    #[test]
361    fn should_not_panic_when_layout_nested() {
362        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
363        let tree = mock_tree();
364        let constraints = [[50, 50], [100, 0]];
365        let directions = [LayoutDirection::Vertical, LayoutDirection::Horizontal];
366        for outer_direction in directions {
367            for inner_direction in directions {
368                for outer_constraint in constraints {
369                    for inner_constraint in constraints {
370                        let widget = TreeWidget::new(&tree);
371                        terminal
372                            .draw(|frame| {
373                                let layout = Layout::default()
374                                    .direction(outer_direction)
375                                    .constraints(Constraint::from_percentages(outer_constraint))
376                                    .split(frame.area());
377                                let nested_layout = Layout::default()
378                                    .direction(inner_direction)
379                                    .constraints(inner_constraint)
380                                    .split(layout[1]);
381                                frame.render_widget(widget, nested_layout[1])
382                            })
383                            .unwrap();
384                    }
385                }
386            }
387        }
388    }
389}