Skip to main content

armas_basic/components/
tree_view.rs

1//! Tree View Component
2//!
3//! A hierarchical tree view for displaying nested items like files/folders.
4
5use crate::ext::ArmasContextExt;
6use egui::{Pos2, Response, Sense, Ui, Vec2};
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11// ============================================================================
12// Constants
13// ============================================================================
14
15const ITEM_GAP: f32 = 0.0;
16const ITEM_PADDING_X: f32 = 8.0;
17const CORNER_RADIUS: f32 = 4.0;
18const INDENT_WIDTH: f32 = 16.0;
19
20// ============================================================================
21// Data Structures
22// ============================================================================
23
24/// A tree view item (file or folder)
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TreeItem {
27    /// Item name
28    pub name: String,
29    /// Item path
30    pub path: PathBuf,
31    /// Whether this is a directory/folder
32    pub is_directory: bool,
33}
34
35impl TreeItem {
36    /// Create a leaf item (file)
37    pub fn leaf(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
38        Self {
39            name: name.into(),
40            path: path.into(),
41            is_directory: false,
42        }
43    }
44
45    /// Create a branch item (folder/directory)
46    pub fn branch(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
47        Self {
48            name: name.into(),
49            path: path.into(),
50            is_directory: true,
51        }
52    }
53
54    /// Create a file item (alias for leaf)
55    pub fn file(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
56        Self::leaf(name, path)
57    }
58
59    /// Create a folder item (alias for branch)
60    pub fn folder(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
61        Self::branch(name, path)
62    }
63}
64
65/// Response from the tree view
66#[derive(Debug, Clone)]
67pub struct TreeViewResponse {
68    /// Base egui response
69    pub response: Response,
70    /// Item selected this frame
71    pub selected: Option<PathBuf>,
72    /// Branch expanded/collapsed this frame
73    pub toggled: Option<PathBuf>,
74}
75
76// ============================================================================
77// Parameter Structs
78// ============================================================================
79
80/// Parameters for `show_level` function
81struct ShowLevelParams<'a> {
82    parent: Option<&'a PathBuf>,
83    width: f32,
84    depth: usize,
85    levels_last: &'a mut Vec<bool>,
86    selected: &'a mut Option<PathBuf>,
87    toggled: &'a mut Option<PathBuf>,
88}
89
90/// Parameters for `show_item` function
91struct ShowItemParams<'a> {
92    item: &'a TreeItem,
93    width: f32,
94    depth: usize,
95    is_last: bool,
96    levels_last: &'a [bool],
97    selected: &'a mut Option<PathBuf>,
98    toggled: &'a mut Option<PathBuf>,
99    theme: &'a crate::Theme,
100}
101
102// ============================================================================
103// TreeView Component
104// ============================================================================
105
106/// Hierarchical tree view component
107///
108/// Displays nested items in a collapsible tree structure.
109///
110/// # Example
111///
112/// ```rust,no_run
113/// # use egui::Ui;
114/// # fn example(ui: &mut Ui) {
115/// use armas_basic::{TreeView, TreeItem};
116///
117/// let items = vec![
118///     TreeItem::folder("src", "/src"),
119///     TreeItem::file("main.rs", "/src/main.rs"),
120///     TreeItem::file("lib.rs", "/src/lib.rs"),
121/// ];
122///
123/// let mut tree = TreeView::new()
124///     .items(items)
125///     .root_path("/");
126///
127/// let response = tree.show(ui);
128/// if let Some(path) = response.selected {
129///     // Handle file selection
130/// }
131/// # }
132/// ```
133#[derive(Clone, Default, Serialize, Deserialize)]
134pub struct TreeView {
135    // State
136    selected: Option<PathBuf>,
137    #[serde(skip)]
138    expanded: HashSet<String>,
139
140    // Config
141    width: f32,
142    height: f32,
143    item_height: f32,
144    items: Vec<TreeItem>,
145    root_path: String,
146    show_lines: bool,
147}
148
149impl TreeView {
150    /// Create a new tree view
151    #[must_use]
152    pub fn new() -> Self {
153        Self {
154            root_path: "/".to_string(),
155            item_height: 24.0,
156            ..Default::default()
157        }
158    }
159
160    /// Set width (0 = fill available)
161    #[must_use]
162    pub const fn width(mut self, width: f32) -> Self {
163        self.width = width;
164        self
165    }
166
167    /// Set height (0 = fill available)
168    #[must_use]
169    pub const fn height(mut self, height: f32) -> Self {
170        self.height = height;
171        self
172    }
173
174    /// Set the height of each item row
175    #[must_use]
176    pub const fn item_height(mut self, height: f32) -> Self {
177        self.item_height = height;
178        self
179    }
180
181    /// Set items
182    #[must_use]
183    pub fn items(mut self, items: Vec<TreeItem>) -> Self {
184        self.items = items;
185        self
186    }
187
188    /// Set root path for filtering
189    #[must_use]
190    pub fn root_path(mut self, path: impl Into<String>) -> Self {
191        self.root_path = path.into();
192        self
193    }
194
195    /// Show tree connection lines
196    #[must_use]
197    pub const fn show_lines(mut self, show: bool) -> Self {
198        self.show_lines = show;
199        self
200    }
201
202    /// Get selected item
203    #[must_use]
204    pub const fn selected(&self) -> Option<&PathBuf> {
205        self.selected.as_ref()
206    }
207
208    /// Check if branch is expanded
209    #[must_use]
210    pub fn is_expanded(&self, path: &Path) -> bool {
211        self.expanded.contains(&path.to_string_lossy().to_string())
212    }
213
214    /// Toggle branch expanded state
215    fn toggle(&mut self, path: &Path) {
216        let key = path.to_string_lossy().to_string();
217        if !self.expanded.remove(&key) {
218            self.expanded.insert(key);
219        }
220    }
221
222    /// Show the tree view
223    pub fn show(&mut self, ui: &mut Ui) -> TreeViewResponse {
224        let theme = ui.ctx().armas_theme();
225        let available = ui.available_size();
226        let width = if self.width > 0.0 {
227            self.width
228        } else {
229            available.x
230        };
231        let height = if self.height > 0.0 {
232            self.height
233        } else {
234            available.y
235        };
236
237        let mut selected_this_frame = None;
238        let mut toggled_this_frame = None;
239
240        let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
241
242        ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
243            egui::ScrollArea::vertical()
244                .id_salt("tree_view_scroll")
245                .show(ui, |ui| {
246                    ui.add_space(theme.spacing.xs);
247                    let mut levels_last = Vec::new();
248                    let params = ShowLevelParams {
249                        parent: None,
250                        width,
251                        depth: 0,
252                        levels_last: &mut levels_last,
253                        selected: &mut selected_this_frame,
254                        toggled: &mut toggled_this_frame,
255                    };
256                    self.show_level(ui, params);
257                });
258        });
259
260        TreeViewResponse {
261            response,
262            selected: selected_this_frame,
263            toggled: toggled_this_frame,
264        }
265    }
266
267    #[allow(clippy::needless_pass_by_value)]
268    fn show_level(&mut self, ui: &mut Ui, params: ShowLevelParams) {
269        let theme = ui.ctx().armas_theme();
270        let items = self.get_children(params.parent);
271        let count = items.len();
272
273        for (i, item) in items.into_iter().enumerate() {
274            let is_expanded = item.is_directory && self.is_expanded(&item.path);
275            let is_last = i == count - 1;
276
277            let show_item_params = ShowItemParams {
278                item: &item,
279                width: params.width,
280                depth: params.depth,
281                is_last,
282                levels_last: params.levels_last,
283                selected: params.selected,
284                toggled: params.toggled,
285                theme: &theme,
286            };
287            self.show_item(ui, show_item_params);
288            ui.add_space(ITEM_GAP);
289
290            if is_expanded {
291                let path = item.path.clone();
292                params.levels_last.push(is_last);
293                let nested_params = ShowLevelParams {
294                    parent: Some(&path),
295                    width: params.width,
296                    depth: params.depth + 1,
297                    levels_last: params.levels_last,
298                    selected: params.selected,
299                    toggled: params.toggled,
300                };
301                self.show_level(ui, nested_params);
302                params.levels_last.pop();
303            }
304        }
305    }
306
307    #[allow(clippy::needless_pass_by_value)]
308    fn show_item(&mut self, ui: &mut Ui, params: ShowItemParams) {
309        let is_selected = self.selected.as_ref() == Some(&params.item.path);
310        let indent = params.depth as f32 * INDENT_WIDTH;
311        let show_lines = self.show_lines && params.depth > 0;
312
313        ui.horizontal(|ui| {
314            // Tree lines prefix
315            if show_lines {
316                let prefix_width = indent;
317                let (prefix_rect, _) = ui
318                    .allocate_exact_size(Vec2::new(prefix_width, self.item_height), Sense::hover());
319
320                let line_color = params.theme.border();
321
322                // Draw vertical lines for each ancestor level
323                for level in 0..params.depth {
324                    let level_x =
325                        prefix_rect.left() + (level as f32 * INDENT_WIDTH) + INDENT_WIDTH / 2.0;
326
327                    // Check if ancestor at this level was the last item
328                    let ancestor_is_last =
329                        level < params.levels_last.len() && params.levels_last[level];
330
331                    if !ancestor_is_last {
332                        // Draw continuing vertical line
333                        ui.painter().line_segment(
334                            [
335                                Pos2::new(level_x, prefix_rect.top()),
336                                Pos2::new(level_x, prefix_rect.bottom()),
337                            ],
338                            egui::Stroke::new(1.0, line_color),
339                        );
340                    }
341                }
342
343                // Vertical line for current level (no horizontal connector)
344                let line_x = prefix_rect.right() - INDENT_WIDTH / 2.0;
345                if !params.is_last {
346                    ui.painter().line_segment(
347                        [
348                            Pos2::new(line_x, prefix_rect.top()),
349                            Pos2::new(line_x, prefix_rect.bottom()),
350                        ],
351                        egui::Stroke::new(1.0, line_color),
352                    );
353                }
354            } else if indent > 0.0 {
355                ui.add_space(indent);
356            }
357
358            let item_width = (params.width - indent - ITEM_PADDING_X).max(40.0);
359            let (rect, response) =
360                ui.allocate_exact_size(Vec2::new(item_width, self.item_height), Sense::click());
361            let hovered = response.hovered();
362
363            // Background
364            if is_selected || hovered {
365                let color = if is_selected {
366                    params.theme.accent()
367                } else {
368                    params.theme.accent().gamma_multiply(0.3)
369                };
370                ui.painter().rect_filled(rect, CORNER_RADIUS, color);
371            }
372
373            // Text color
374            let text_color = if is_selected {
375                params.theme.accent_foreground()
376            } else {
377                params.theme.foreground()
378            };
379
380            let x = rect.left() + ITEM_PADDING_X;
381
382            let display_name = &params.item.name;
383
384            ui.painter().text(
385                Pos2::new(x, rect.center().y),
386                egui::Align2::LEFT_CENTER,
387                display_name,
388                egui::FontId::proportional(13.0),
389                text_color,
390            );
391
392            // Handle click
393            if response.clicked() {
394                if params.item.is_directory {
395                    self.toggle(&params.item.path);
396                    *params.toggled = Some(params.item.path.clone());
397                } else {
398                    self.selected = Some(params.item.path.clone());
399                    *params.selected = Some(params.item.path.clone());
400                }
401            }
402        });
403    }
404
405    fn get_children(&self, parent: Option<&PathBuf>) -> Vec<TreeItem> {
406        let root = PathBuf::from(&self.root_path);
407
408        let mut items: Vec<_> = self
409            .items
410            .iter()
411            .filter(|item| {
412                let item_parent = item.path.parent();
413                match (parent, item_parent) {
414                    (None, Some(p)) => p == root,
415                    (Some(expected), Some(actual)) => actual == expected.as_path(),
416                    _ => false,
417                }
418            })
419            .cloned()
420            .collect();
421
422        // Sort: folders first, then by name
423        items.sort_by(|a, b| match (a.is_directory, b.is_directory) {
424            (true, false) => std::cmp::Ordering::Less,
425            (false, true) => std::cmp::Ordering::Greater,
426            _ => a.name.cmp(&b.name),
427        });
428
429        items
430    }
431}
432
433// Backwards compatibility aliases
434#[doc(hidden)]
435pub type Browser = TreeView;
436#[doc(hidden)]
437pub type BrowserItem = TreeItem;
438#[doc(hidden)]
439pub type BrowserResponse = TreeViewResponse;