Skip to main content

slt/context/widgets_interactive/
tree_widgets.rs

1use super::*;
2use crate::{DirectoryTreeState, TreeNode};
3
4impl Context {
5    /// Render a tree view. Left/Right to collapse/expand, Up/Down to navigate.
6    pub fn tree(&mut self, state: &mut TreeState) -> Response {
7        let entries = state.flatten();
8        if entries.is_empty() {
9            return Response::none();
10        }
11        state.selected = state.selected.min(entries.len().saturating_sub(1));
12        let old_selected = state.selected;
13        let focused = self.register_focusable();
14        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
15        let mut changed = false;
16
17        if focused {
18            let mut consumed_indices = Vec::new();
19            let max_index = entries.len().saturating_sub(1);
20            for (i, key) in self.available_key_presses() {
21                match key.code {
22                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
23                        let _ =
24                            handle_vertical_nav(&mut state.selected, max_index, key.code.clone());
25                        changed = changed || state.selected != old_selected;
26                        consumed_indices.push(i);
27                    }
28                    KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
29                        state.toggle_at(state.selected);
30                        changed = true;
31                        consumed_indices.push(i);
32                    }
33                    KeyCode::Left => {
34                        let entry = &entries[state.selected.min(entries.len() - 1)];
35                        if entry.expanded {
36                            state.toggle_at(state.selected);
37                            changed = true;
38                        }
39                        consumed_indices.push(i);
40                    }
41                    _ => {}
42                }
43            }
44            self.consume_indices(consumed_indices);
45        }
46
47        self.commands
48            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
49                direction: Direction::Column,
50                gap: 0,
51                align: Align::Start,
52                align_self: None,
53                justify: Justify::Start,
54                border: None,
55                border_sides: BorderSides::all(),
56                border_style: Style::new().fg(self.theme.border),
57                bg_color: None,
58                padding: Padding::default(),
59                margin: Margin::default(),
60                constraints: Constraints::default(),
61                title: None,
62                grow: 0,
63                group_name: None,
64            })));
65
66        let entries = state.flatten();
67        for (idx, entry) in entries.iter().enumerate() {
68            let indent = "  ".repeat(entry.depth);
69            let icon = if entry.is_leaf {
70                "  "
71            } else if entry.expanded {
72                "▾ "
73            } else {
74                "▸ "
75            };
76            let is_selected = idx == state.selected;
77            let style = if is_selected && focused {
78                Style::new().bold().fg(self.theme.primary)
79            } else if is_selected {
80                Style::new().fg(self.theme.primary)
81            } else {
82                Style::new().fg(self.theme.text)
83            };
84            let cursor = if is_selected && focused { "▸" } else { " " };
85            let mut row =
86                String::with_capacity(cursor.len() + indent.len() + icon.len() + entry.label.len());
87            row.push_str(cursor);
88            row.push_str(&indent);
89            row.push_str(icon);
90            row.push_str(&entry.label);
91            self.styled(row, style);
92        }
93
94        self.commands.push(Command::EndContainer);
95        self.rollback.last_text_idx = None;
96        response.changed = changed || state.selected != old_selected;
97        response
98    }
99
100    /// Render a directory tree with guide lines and tree connectors.
101    pub fn directory_tree(&mut self, state: &mut DirectoryTreeState) -> Response {
102        let entries = state.tree.flatten();
103        if entries.is_empty() {
104            return Response::none();
105        }
106        state.tree.selected = state.tree.selected.min(entries.len().saturating_sub(1));
107        let old_selected = state.tree.selected;
108        let focused = self.register_focusable();
109        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
110        let mut changed = false;
111
112        if focused {
113            let mut consumed_indices = Vec::new();
114            // Per-keypress arms are mutually exclusive, and Right/Left only
115            // call `toggle_at` AFTER inspecting the entry. Reusing the outer
116            // `entries` snapshot is therefore safe within a single pass.
117            let max_index = entries.len().saturating_sub(1);
118            for (i, key) in self.available_key_presses() {
119                match key.code {
120                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
121                        let _ = handle_vertical_nav(
122                            &mut state.tree.selected,
123                            max_index,
124                            key.code.clone(),
125                        );
126                        changed = changed || state.tree.selected != old_selected;
127                        consumed_indices.push(i);
128                    }
129                    KeyCode::Right => {
130                        let entry = &entries[state.tree.selected.min(entries.len() - 1)];
131                        if !entry.is_leaf && !entry.expanded {
132                            state.tree.toggle_at(state.tree.selected);
133                            changed = true;
134                        }
135                        consumed_indices.push(i);
136                    }
137                    KeyCode::Enter | KeyCode::Char(' ') => {
138                        state.tree.toggle_at(state.tree.selected);
139                        changed = true;
140                        consumed_indices.push(i);
141                    }
142                    KeyCode::Left => {
143                        let entry = &entries[state.tree.selected.min(entries.len() - 1)];
144                        if entry.expanded {
145                            state.tree.toggle_at(state.tree.selected);
146                            changed = true;
147                        }
148                        consumed_indices.push(i);
149                    }
150                    _ => {}
151                }
152            }
153            self.consume_indices(consumed_indices);
154        }
155
156        self.commands
157            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
158                direction: Direction::Column,
159                gap: 0,
160                align: Align::Start,
161                align_self: None,
162                justify: Justify::Start,
163                border: None,
164                border_sides: BorderSides::all(),
165                border_style: Style::new().fg(self.theme.border),
166                bg_color: None,
167                padding: Padding::default(),
168                margin: Margin::default(),
169                constraints: Constraints::default(),
170                title: None,
171                grow: 0,
172                group_name: None,
173            })));
174
175        let mut rows = Vec::new();
176        flatten_directory_rows(&state.tree.nodes, Vec::new(), &mut rows);
177        for (idx, row_entry) in rows.iter().enumerate() {
178            let mut row = String::new();
179            let cursor = if idx == state.tree.selected && focused {
180                "▸"
181            } else {
182                " "
183            };
184            row.push_str(cursor);
185            row.push(' ');
186
187            if row_entry.depth > 0 {
188                for has_more in &row_entry.branch_mask {
189                    if *has_more {
190                        row.push_str("│   ");
191                    } else {
192                        row.push_str("    ");
193                    }
194                }
195                if row_entry.is_last {
196                    row.push_str("└── ");
197                } else {
198                    row.push_str("├── ");
199                }
200            }
201
202            let icon = if row_entry.is_leaf {
203                "  "
204            } else if row_entry.expanded {
205                "▾ "
206            } else {
207                "▸ "
208            };
209            if state.show_icons {
210                row.push_str(icon);
211            }
212            row.push_str(&row_entry.label);
213
214            let style = if idx == state.tree.selected && focused {
215                Style::new().bold().fg(self.theme.primary)
216            } else if idx == state.tree.selected {
217                Style::new().fg(self.theme.primary)
218            } else {
219                Style::new().fg(self.theme.text)
220            };
221            self.styled(row, style);
222        }
223
224        self.commands.push(Command::EndContainer);
225        self.rollback.last_text_idx = None;
226        response.changed = changed || state.tree.selected != old_selected;
227        response
228    }
229}
230
231struct DirectoryRenderRow {
232    depth: usize,
233    label: String,
234    is_leaf: bool,
235    expanded: bool,
236    is_last: bool,
237    branch_mask: Vec<bool>,
238}
239
240fn flatten_directory_rows(
241    nodes: &[TreeNode],
242    branch_mask: Vec<bool>,
243    out: &mut Vec<DirectoryRenderRow>,
244) {
245    for (idx, node) in nodes.iter().enumerate() {
246        let is_last = idx + 1 == nodes.len();
247        out.push(DirectoryRenderRow {
248            depth: branch_mask.len(),
249            label: node.label.clone(),
250            is_leaf: node.children.is_empty(),
251            expanded: node.expanded,
252            is_last,
253            branch_mask: branch_mask.clone(),
254        });
255
256        if node.expanded && !node.children.is_empty() {
257            let mut next_mask = branch_mask.clone();
258            next_mask.push(!is_last);
259            flatten_directory_rows(&node.children, next_mask, out);
260        }
261    }
262}