Skip to main content

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::text::Line;
9use tuirealm::ratatui::widgets::{Block, StatefulWidget, Widget};
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<Line<'a>>,
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_str<S: Into<Line<'a>>>(mut self, s: S) -> Self {
62        self.highlight_symbol = Some(s.into());
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 => self.highlight_symbol.as_ref(),
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| {
191                buf.set_line(
192                    start_x,
193                    start_y,
194                    x,
195                    u16::try_from(width - start_x as usize).unwrap_or(u16::MAX),
196                )
197            })
198            .map(|(x, y)| buf.set_stringn(x, y, " ", width - start_x as usize, style))
199            .unwrap_or((start_x, start_y));
200
201        let mut start_x = start_x;
202        let mut start_y = start_y;
203        for (text, part_style) in node.value().render_parts_iter() {
204            let part_style = part_style.unwrap_or(style);
205            // Write node name
206            (start_x, start_y) =
207                buf.set_stringn(start_x, start_y, text, width - start_x as usize, part_style);
208        }
209        // Write arrow based on node
210        let write_after = if state.is_open(node) {
211            // Is open
212            " \u{25bc}" // Arrow down
213        } else if node.is_leaf() {
214            // Is leaf (has no children)
215            "  "
216        } else {
217            // Has children, but is closed
218            " \u{25b6}" // Arrow to right
219        };
220        let _ = buf.set_stringn(
221            start_x,
222            start_y,
223            write_after,
224            width - start_x as usize,
225            style,
226        );
227        // Return new area
228        Rect {
229            x: area.x,
230            y: area.y + 1,
231            width: area.width,
232            height: area.height - 1,
233        }
234    }
235
236    /// Calculate rows to skip before starting rendering the current tree
237    fn calc_rows_to_skip(&self, state: &TreeState, height: u16) -> usize {
238        // if no node is selected, return 0
239        let selected = match state.selected() {
240            Some(s) => s,
241            None => return 0,
242        };
243
244        /// Recursive visit each node (excluding closed ones) and calculate full size and index of selected node
245        fn visit_nodes<V: NodeValue>(
246            node: &Node<V>,
247            state: &TreeState,
248            selected: &str,
249            selected_idx: &mut usize,
250            size: &mut usize,
251        ) {
252            *size += 1;
253            if node.id().as_str() == selected {
254                *selected_idx = *size;
255            }
256
257            if !state.is_closed(node) {
258                for child in node.iter() {
259                    visit_nodes(child, state, selected, selected_idx, size);
260                }
261            }
262        }
263
264        let selected_idx: &mut usize = &mut 0;
265        let size = &mut 0;
266        visit_nodes(self.tree.root(), state, selected, selected_idx, size);
267
268        let render_area_h = height as usize;
269        let num_lines_to_show_at_top = render_area_h / 2;
270        let offset_max = (*size).saturating_sub(render_area_h);
271        (*selected_idx)
272            .saturating_sub(num_lines_to_show_at_top)
273            .min(offset_max)
274    }
275}
276
277#[cfg(test)]
278mod test {
279
280    use pretty_assertions::assert_eq;
281    use tuirealm::ratatui::Terminal;
282    use tuirealm::ratatui::backend::TestBackend;
283    use tuirealm::ratatui::layout::{Constraint, Direction as LayoutDirection, Layout};
284    use tuirealm::ratatui::style::Color;
285
286    use super::*;
287    use crate::mock::mock_tree;
288
289    #[test]
290    fn should_construct_default_widget() {
291        let tree = mock_tree();
292        let widget = TreeWidget::new(&tree);
293        assert_eq!(widget.block, None);
294        assert_eq!(widget.highlight_style, Style::default());
295        assert_eq!(widget.highlight_symbol, None);
296        assert_eq!(widget.indent_size, 4);
297        assert_eq!(widget.style, Style::default());
298    }
299
300    #[test]
301    fn should_construct_widget() {
302        let tree = mock_tree();
303        let widget = TreeWidget::new(&tree)
304            .block(Block::default())
305            .highlight_style(Style::default().fg(Color::Red))
306            .highlight_str(">")
307            .indent_size(8)
308            .style(Style::default().fg(Color::LightRed));
309        assert!(widget.block.is_some());
310        assert_eq!(widget.highlight_style.fg.unwrap(), Color::Red);
311        assert_eq!(widget.indent_size, 8);
312        assert_eq!(widget.highlight_symbol.unwrap(), Line::raw(">"));
313        assert_eq!(widget.style.fg.unwrap(), Color::LightRed);
314    }
315
316    #[test]
317    fn should_have_no_row_to_skip_when_in_first_height_elements() {
318        let tree = mock_tree();
319        let mut state = TreeState::default();
320        // Select aA2
321        let aa2 = tree.root().query(&String::from("aA2")).unwrap();
322        state.select(tree.root(), aa2);
323        // Get rows to skip (no block)
324        let widget = TreeWidget::new(&tree);
325        // Before end
326        assert_eq!(widget.calc_rows_to_skip(&state, 8), 2);
327        // At end
328        assert_eq!(widget.calc_rows_to_skip(&state, 6), 3);
329    }
330
331    #[test]
332    fn should_have_rows_to_skip_when_out_of_viewport() {
333        let tree = mock_tree();
334        let mut state = TreeState::default();
335        // Open all previous nodes
336        state.force_open(&["/", "a", "aA", "aB", "aC", "b", "bA", "bB"]);
337        // Select bB2
338        let bb2 = tree.root().query(&String::from("bB2")).unwrap();
339        state.select(tree.root(), bb2);
340        // Get rows to skip (no block)
341        let widget = TreeWidget::new(&tree);
342        // 20th element - height (12) + 1
343        assert_eq!(widget.calc_rows_to_skip(&state, 8), 17);
344    }
345
346    #[test]
347    fn should_not_panic_per_layout_direction() {
348        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
349        let tree = mock_tree();
350        let constraints = [[50, 50], [100, 0]];
351        for direction in [LayoutDirection::Vertical, LayoutDirection::Horizontal] {
352            for constraint in constraints {
353                let widget = TreeWidget::new(&tree);
354                terminal
355                    .draw(|frame| {
356                        let layout = Layout::default()
357                            .direction(direction)
358                            .constraints(Constraint::from_percentages(constraint))
359                            .split(frame.area());
360                        frame.render_widget(widget, layout[1])
361                    })
362                    .unwrap();
363            }
364        }
365    }
366
367    #[test]
368    fn should_not_panic_when_layout_nested() {
369        let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
370        let tree = mock_tree();
371        let constraints = [[50, 50], [100, 0]];
372        let directions = [LayoutDirection::Vertical, LayoutDirection::Horizontal];
373        for outer_direction in directions {
374            for inner_direction in directions {
375                for outer_constraint in constraints {
376                    for inner_constraint in constraints {
377                        let widget = TreeWidget::new(&tree);
378                        terminal
379                            .draw(|frame| {
380                                let layout = Layout::default()
381                                    .direction(outer_direction)
382                                    .constraints(Constraint::from_percentages(outer_constraint))
383                                    .split(frame.area());
384                                let nested_layout = Layout::default()
385                                    .direction(inner_direction)
386                                    .constraints(inner_constraint)
387                                    .split(layout[1]);
388                                frame.render_widget(widget, nested_layout[1])
389                            })
390                            .unwrap();
391                    }
392                }
393            }
394        }
395    }
396}