tui_treelistview/
widget.rs

1use ratatui::layout::{Constraint, Rect};
2use ratatui::prelude::Buffer;
3use ratatui::widgets::{
4    Block, Borders, Cell, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
5    Table, TableState,
6};
7use smallvec::SmallVec;
8
9use crate::columns::TreeColumns;
10use crate::context::TreeRowContext;
11use crate::glyphs::{TreeGlyphs, TreeLabelRenderer};
12use crate::model::{NoFilter, TreeFilter, TreeFilterConfig, TreeModel};
13use crate::state::{TreeListViewState, VisibleNode};
14use crate::style::TreeListViewStyle;
15
16/// Основной виджет дерева (table + stateful).
17pub struct TreeListView<'a, T, L, C, F = NoFilter>
18where
19    T: TreeModel,
20    L: TreeLabelRenderer<T>,
21    C: TreeColumns<T>,
22    F: TreeFilter<T>,
23{
24    model: &'a T,
25    label: &'a L,
26    columns: &'a C,
27    style: TreeListViewStyle<'a>,
28    glyphs: TreeGlyphs<'a>,
29    filter: F,
30    filter_config: TreeFilterConfig,
31}
32
33impl<'a, T, L, C> TreeListView<'a, T, L, C, NoFilter>
34where
35    T: TreeModel,
36    L: TreeLabelRenderer<T>,
37    C: TreeColumns<T>,
38{
39    pub const fn new(
40        model: &'a T,
41        label: &'a L,
42        columns: &'a C,
43        style: TreeListViewStyle<'a>,
44    ) -> Self {
45        Self {
46            model,
47            label,
48            columns,
49            style,
50            glyphs: TreeGlyphs::unicode(),
51            filter: NoFilter,
52            filter_config: TreeFilterConfig::disabled(),
53        }
54    }
55
56    pub const fn glyphs(mut self, glyphs: TreeGlyphs<'a>) -> Self {
57        self.glyphs = glyphs;
58        self
59    }
60
61    pub fn with_filter<F>(
62        self,
63        filter: F,
64        filter_config: TreeFilterConfig,
65    ) -> TreeListView<'a, T, L, C, F>
66    where
67        F: TreeFilter<T>,
68    {
69        TreeListView {
70            model: self.model,
71            label: self.label,
72            columns: self.columns,
73            style: self.style,
74            glyphs: self.glyphs,
75            filter,
76            filter_config,
77        }
78    }
79}
80
81impl<'a, T, L, C, F> TreeListView<'a, T, L, C, F>
82where
83    T: TreeModel,
84    L: TreeLabelRenderer<T>,
85    C: TreeColumns<T>,
86    F: TreeFilter<T>,
87{
88    #[inline]
89    fn build_rows(
90        &self,
91        nodes: &[VisibleNode<T::Id>],
92        state: &TreeListViewState<T::Id>,
93    ) -> Vec<Row<'a>> {
94        let mut rows = Vec::with_capacity(nodes.len());
95        for node in nodes {
96            let has_children = !self.model.children(node.id).is_empty();
97            let is_expanded = state.is_expanded(node.parent, node.id);
98            let is_marked = state.node_is_marked(node.id);
99            let ctx = TreeRowContext {
100                level: node.level,
101                is_tail_stack: node.is_tail_stack.as_slice(),
102                is_expanded,
103                has_children,
104                is_marked,
105                draw_lines: state.draw_lines(),
106                line_style: self.style.line_style,
107            };
108            let label_cell = self.label.cell(self.model, node.id, &ctx, &self.glyphs);
109            let mut cells = SmallVec::<[Cell; 8]>::new();
110            cells.push(label_cell);
111            cells.extend(self.columns.cells(self.model, node.id));
112            let mut row = Row::new(cells);
113            if is_marked {
114                row = row.style(self.style.mark_style);
115            }
116            rows.push(row);
117        }
118        rows
119    }
120
121    #[inline]
122    fn build_table(
123        &self,
124        rows: Vec<Row<'a>>,
125        constraints: &[Constraint],
126        block: Block<'a>,
127        header: Option<Row<'a>>,
128    ) -> Table<'a> {
129        let mut table = Table::new(rows, constraints.iter().copied())
130            .style(self.style.block_style)
131            .block(block)
132            .row_highlight_style(self.style.highlight_style)
133            .highlight_symbol(self.style.highlight_symbol);
134        if let Some(header) = header {
135            table = table.header(header);
136        }
137        table
138    }
139
140    #[inline]
141    fn render_scrollbar(
142        &self,
143        area: Rect,
144        buf: &mut Buffer,
145        state: &TreeListViewState<T::Id>,
146        inner_height: usize,
147        scroll_rows: usize,
148    ) {
149        let scroll_len = scroll_rows.saturating_add(1);
150        let position = state
151            .list_state()
152            .offset()
153            .min(scroll_len.saturating_sub(1));
154        let mut scrollbar_state = ScrollbarState::new(scroll_len)
155            .position(position)
156            .viewport_content_length(inner_height);
157        Scrollbar::default()
158            .orientation(ScrollbarOrientation::VerticalRight)
159            .render(area, buf, &mut scrollbar_state);
160    }
161}
162
163impl<T, L, C, F> StatefulWidget for TreeListView<'_, T, L, C, F>
164where
165    T: TreeModel,
166    L: TreeLabelRenderer<T>,
167    C: TreeColumns<T>,
168    F: TreeFilter<T>,
169{
170    type State = TreeListViewState<T::Id>;
171
172    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
173        if self.filter_config.enabled {
174            state.ensure_visible_nodes_filtered(self.model, &self.filter, self.filter_config);
175        } else {
176            state.ensure_visible_nodes(self.model);
177        }
178        state.ensure_mark_cache(self.model);
179
180        let header = self.columns.header();
181        let header_height = u16::from(header.is_some());
182
183        let mut block = Block::default().borders(self.style.borders);
184        if let Some(title) = self.style.title.clone() {
185            block = block.title(title);
186        }
187        block = block
188            .style(self.style.block_style)
189            .border_style(self.style.border_style);
190
191        let inner_height = block.inner(area).height.saturating_sub(header_height) as usize;
192        state.ensure_selection_visible_with_policy(inner_height, self.style.scroll_policy);
193
194        let visible_nodes = state.visible_nodes();
195        let total_rows = visible_nodes.len();
196        let (range_start, range_end) = if self.style.virtualize_rows {
197            let start = state.list_state().offset().min(total_rows);
198            let end = (start + inner_height).min(total_rows);
199            (start, end)
200        } else {
201            (0, total_rows)
202        };
203
204        let nodes = &visible_nodes[range_start..range_end];
205        let rows = self.build_rows(nodes, state);
206
207        let scroll_rows = total_rows.saturating_sub(inner_height);
208
209        let mut local_state = if self.style.virtualize_rows {
210            Some(*state.list_state())
211        } else {
212            None
213        };
214        let table_state: &mut TableState = local_state.as_mut().map_or_else(
215            || state.list_state_mut(),
216            |state_ref| {
217                *state_ref.offset_mut() = 0;
218                if let Some(selected) = state_ref.selected() {
219                    if selected < range_start || selected >= range_end {
220                        state_ref.select(None);
221                    } else {
222                        state_ref.select(Some(selected - range_start));
223                    }
224                }
225                state_ref
226            },
227        );
228
229        let (table_area, table_block, constraints, header, scrollbar_area) = if scroll_rows > 0 {
230            let table_area = Rect {
231                width: area.width.saturating_sub(1),
232                ..area
233            };
234            let scrollbar_area = Rect {
235                x: area.x + area.width - 1,
236                y: area.y,
237                width: 1,
238                height: area.height,
239            };
240            let mut table_borders = self.style.borders;
241            table_borders.remove(Borders::RIGHT);
242            let table_block = block.borders(table_borders);
243            let constraints = self
244                .columns
245                .constraints_for_area(table_block.inner(table_area));
246            (
247                table_area,
248                table_block,
249                constraints,
250                header.clone(),
251                Some(scrollbar_area),
252            )
253        } else {
254            let constraints = self.columns.constraints_for_area(block.inner(area));
255            (area, block, constraints, header, None)
256        };
257
258        let table = self.build_table(rows, constraints.as_slice(), table_block, header);
259        table.render(table_area, buf, table_state);
260
261        if let Some(scrollbar_area) = scrollbar_area {
262            self.render_scrollbar(scrollbar_area, buf, state, inner_height, scroll_rows);
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use ratatui::layout::Constraint;
271    use ratatui::widgets::StatefulWidget;
272
273    struct TestModel {
274        children: Vec<Vec<usize>>,
275        names: Vec<String>,
276    }
277
278    impl TestModel {
279        fn new(child_count: usize) -> Self {
280            let mut children = vec![Vec::new(); child_count + 1];
281            let mut names = Vec::with_capacity(child_count + 1);
282            names.push("root".to_string());
283            for idx in 1..=child_count {
284                children[0].push(idx);
285                names.push(format!("node-{idx}"));
286            }
287            Self { children, names }
288        }
289    }
290
291    impl TreeModel for TestModel {
292        type Id = usize;
293
294        fn root(&self) -> Option<Self::Id> {
295            Some(0)
296        }
297
298        fn children(&self, id: Self::Id) -> &[Self::Id] {
299            &self.children[id]
300        }
301
302        fn contains(&self, id: Self::Id) -> bool {
303            id < self.children.len()
304        }
305    }
306
307    struct Label;
308
309    impl TreeLabelRenderer<TestModel> for Label {
310        fn cell<'a>(
311            &'a self,
312            model: &'a TestModel,
313            id: usize,
314            _ctx: &TreeRowContext,
315            _glyphs: &TreeGlyphs<'a>,
316        ) -> Cell<'a> {
317            Cell::from(model.names[id].as_str())
318        }
319    }
320
321    struct Columns;
322
323    impl TreeColumns<TestModel> for Columns {
324        fn label_constraint(&self) -> Constraint {
325            Constraint::Percentage(100)
326        }
327
328        fn other_constraints(&self) -> &[Constraint] {
329            &[]
330        }
331
332        fn cells<'a>(&'a self, _model: &'a TestModel, _id: usize) -> SmallVec<[Cell<'a>; 8]> {
333            SmallVec::new()
334        }
335    }
336
337    #[test]
338    fn render_smoke_with_scrollbar() {
339        let model = TestModel::new(12);
340        let label = Label;
341        let columns = Columns;
342        let style = TreeListViewStyle::default();
343        let widget = TreeListView::new(&model, &label, &columns, style);
344
345        let mut state = TreeListViewState::new();
346        state.set_expanded(0, None, true);
347
348        let area = Rect::new(0, 0, 20, 6);
349        let mut buffer = Buffer::empty(area);
350
351        widget.render(area, &mut buffer, &mut state);
352    }
353}