Skip to main content

iced_shadcn/
tree_view.rs

1use iced::alignment::Vertical;
2use iced::border::Border;
3use iced::widget::{Space, button as iced_button, column, container, lazy, row, rule, stack, text};
4use iced::{Background, Color, Element, Font, Length, Padding};
5use lucide_icons::Icon as LucideIcon;
6
7use crate::theme::Theme;
8
9// ---------------------------------------------------------------------------
10// TreeNode – declarative tree data model
11// ---------------------------------------------------------------------------
12
13/// State of a folder node, for lazy loading.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum FolderState {
16    /// Folder contents are not yet loaded.
17    Unloaded,
18    /// Folder contents are currently being loaded.
19    Loading,
20    /// Folder contents are fully loaded.
21    Loaded,
22}
23
24/// A single node in the tree.  Can be either a folder (with children) or a
25/// file (leaf).
26#[derive(Clone, Debug)]
27pub enum TreeNode {
28    Folder {
29        name: String,
30        children: Vec<TreeNode>,
31        icon_open: Option<LucideIcon>,
32        icon_closed: Option<LucideIcon>,
33        state: FolderState,
34    },
35    File {
36        name: String,
37        icon: Option<LucideIcon>,
38    },
39}
40
41impl TreeNode {
42    /// Convenience constructor for a folder.
43    pub fn folder(name: impl Into<String>, children: Vec<TreeNode>) -> Self {
44        Self::Folder {
45            name: name.into(),
46            children,
47            icon_open: None,
48            icon_closed: None,
49            state: FolderState::Loaded,
50        }
51    }
52
53    /// Convenience constructor for an unloaded folder.
54    pub fn unloaded_folder(name: impl Into<String>) -> Self {
55        Self::Folder {
56            name: name.into(),
57            children: vec![],
58            icon_open: None,
59            icon_closed: None,
60            state: FolderState::Unloaded,
61        }
62    }
63
64    /// Convenience constructor for a file.
65    pub fn file(name: impl Into<String>) -> Self {
66        Self::File {
67            name: name.into(),
68            icon: None,
69        }
70    }
71
72    /// Set a custom icon for the file node.
73    pub fn with_icon(mut self, icon: LucideIcon) -> Self {
74        match &mut self {
75            Self::File { icon: i, .. } => *i = Some(icon),
76            Self::Folder { .. } => {}
77        }
78        self
79    }
80
81    /// Set custom icons for the folder node (open / closed states).
82    pub fn with_folder_icons(mut self, open: LucideIcon, closed: LucideIcon) -> Self {
83        match &mut self {
84            Self::Folder {
85                icon_open,
86                icon_closed,
87                ..
88            } => {
89                *icon_open = Some(open);
90                *icon_closed = Some(closed);
91            }
92            Self::File { .. } => {}
93        }
94        self
95    }
96
97    /// Set the folder state (useful for setting generic Loading state).
98    pub fn with_state(mut self, new_state: FolderState) -> Self {
99        if let Self::Folder { state, .. } = &mut self {
100            *state = new_state;
101        }
102        self
103    }
104
105    fn name(&self) -> &str {
106        match self {
107            Self::Folder { name, .. } | Self::File { name, .. } => name,
108        }
109    }
110}
111
112// ---------------------------------------------------------------------------
113// TreeViewState – tracks expand/collapse & selection
114// ---------------------------------------------------------------------------
115
116/// Persistent state for the tree: which folders are open and which file is
117/// selected.  Keep this in your application `struct`.
118#[derive(Clone, Debug, Default)]
119pub struct TreeViewState {
120    /// Set of folder paths (joined with `/`) that are currently expanded.
121    pub open_folders: Vec<String>,
122    /// Path to the currently-selected file, if any.
123    pub selected: Option<String>,
124}
125
126impl TreeViewState {
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Create state with all folders matching `paths` expanded.
132    pub fn with_open(paths: Vec<String>) -> Self {
133        Self {
134            open_folders: paths,
135            selected: None,
136        }
137    }
138
139    pub fn is_open(&self, path: &str) -> bool {
140        self.open_folders.iter().any(|p| p == path)
141    }
142
143    pub fn toggle_folder(&mut self, path: &str) {
144        if let Some(idx) = self.open_folders.iter().position(|p| p == path) {
145            self.open_folders.remove(idx);
146        } else {
147            self.open_folders.push(path.to_string());
148        }
149    }
150
151    pub fn open_folder(&mut self, path: &str) {
152        if !self.is_open(path) {
153            self.open_folders.push(path.to_string());
154        }
155    }
156
157    pub fn select(&mut self, path: &str) {
158        self.selected = Some(path.to_string());
159    }
160
161    pub fn is_selected(&self, path: &str) -> bool {
162        self.selected.as_deref() == Some(path)
163    }
164
165    /// Expand all folders in the tree.
166    pub fn expand_all(nodes: &[TreeNode]) -> Self {
167        let mut paths = Vec::new();
168        collect_folder_paths(nodes, "", &mut paths);
169        Self {
170            open_folders: paths,
171            selected: None,
172        }
173    }
174}
175
176fn collect_folder_paths(nodes: &[TreeNode], prefix: &str, out: &mut Vec<String>) {
177    for node in nodes {
178        if let TreeNode::Folder { name, children, .. } = node {
179            let path = if prefix.is_empty() {
180                name.clone()
181            } else {
182                format!("{prefix}/{name}")
183            };
184            out.push(path.clone());
185            collect_folder_paths(children, &path, out);
186        }
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Props
192// ---------------------------------------------------------------------------
193
194#[derive(Clone, Copy, Debug)]
195pub struct TreeViewProps {
196    /// Indent per nesting level in pixels.
197    pub indent: f32,
198    /// Icon size in pixels.
199    pub icon_size: f32,
200    /// Font size for labels.
201    pub font_size: f32,
202    /// Row height.
203    pub row_height: f32,
204    /// Whether file clicks emit messages.
205    pub selectable: bool,
206    /// Max characters before label is truncated with "…".
207    pub max_label_chars: usize,
208    /// Extra left shift for row content (independent from hover background).
209    pub content_offset: f32,
210    /// Scrollbar visibility behavior for the tree viewport.
211    pub scrollbar_visibility: TreeScrollbarVisibility,
212}
213
214impl Default for TreeViewProps {
215    fn default() -> Self {
216        Self {
217            indent: 16.0,
218            icon_size: 16.0,
219            font_size: 13.0,
220            row_height: 28.0,
221            selectable: true,
222            max_label_chars: 30,
223            content_offset: 0.0,
224            scrollbar_visibility: TreeScrollbarVisibility::Auto,
225        }
226    }
227}
228
229impl TreeViewProps {
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    pub fn indent(mut self, indent: f32) -> Self {
235        self.indent = indent;
236        self
237    }
238
239    pub fn icon_size(mut self, size: f32) -> Self {
240        self.icon_size = size;
241        self
242    }
243
244    pub fn font_size(mut self, size: f32) -> Self {
245        self.font_size = size;
246        self
247    }
248
249    pub fn row_height(mut self, height: f32) -> Self {
250        self.row_height = height;
251        self
252    }
253
254    pub fn selectable(mut self, selectable: bool) -> Self {
255        self.selectable = selectable;
256        self
257    }
258
259    pub fn max_label_chars(mut self, n: usize) -> Self {
260        self.max_label_chars = n;
261        self
262    }
263
264    pub fn content_offset(mut self, offset: f32) -> Self {
265        self.content_offset = offset.max(0.0);
266        self
267    }
268
269    pub fn scrollbar_visibility(mut self, visibility: TreeScrollbarVisibility) -> Self {
270        self.scrollbar_visibility = visibility;
271        self
272    }
273}
274
275/// Vertical scrollbar visibility policy for the tree view.
276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
277pub enum TreeScrollbarVisibility {
278    /// Hidden by default, visible on hover/drag.
279    Auto,
280    /// Always visible.
281    Visible,
282    /// Always hidden.
283    Hidden,
284}
285
286// ---------------------------------------------------------------------------
287// Messages the tree can emit
288// ---------------------------------------------------------------------------
289
290/// Messages produced by the tree view.  Map these in your application `update`.
291#[derive(Clone, Debug)]
292pub enum TreeViewAction {
293    /// A folder was toggled (path).
294    ToggleFolder(String),
295    /// A file was selected (path).
296    SelectFile(String),
297    /// An Unloaded folder was clicked and needs to load data (path).
298    LoadFolder(String),
299}
300
301// ---------------------------------------------------------------------------
302// Helpers
303// ---------------------------------------------------------------------------
304
305fn truncate_ellipsis(s: &str, max_chars: usize) -> String {
306    if max_chars == 0 || s.chars().count() <= max_chars {
307        return s.to_string();
308    }
309    if max_chars <= 3 {
310        return ".".repeat(max_chars);
311    }
312    let truncated: String = s.chars().take(max_chars - 3).collect();
313    format!("{truncated}...")
314}
315
316// ---------------------------------------------------------------------------
317// Public render function
318// ---------------------------------------------------------------------------
319
320/// Render a tree view widget.
321///
322/// * `nodes`  – the tree data.
323/// * `state`  – current expand/selection state.
324/// * `on_action` – closure that wraps [`TreeViewAction`] into your app `Message`.
325/// * `props`  – visual tuning knobs.
326/// * `theme`  – shadcn theme.
327pub fn tree_view<'a, Message: Clone + 'static>(
328    nodes: Vec<TreeNode>,
329    state: TreeViewState,
330    on_action: impl Fn(TreeViewAction) -> Message + 'static + Clone,
331    props: TreeViewProps,
332    theme: &Theme,
333) -> Element<'a, Message> {
334    let mut col = column![].spacing(0).width(Length::Fill);
335
336    for node in &nodes {
337        col = col.push(render_node(
338            node,
339            &state,
340            on_action.clone(),
341            props,
342            theme,
343            0,
344            "",
345        ));
346    }
347
348    let inner = container(col)
349        .width(Length::Fill)
350        .height(Length::Fill)
351        .padding(Padding {
352            top: 0.0,
353            right: 0.0,
354            bottom: 24.0,
355            left: 0.0,
356        });
357
358    crate::scroll_area::scroll_area(
359        inner,
360        crate::scroll_area::ScrollAreaProps::new()
361            .scrollbars(crate::scroll_area::ScrollAreaScrollbars::Vertical)
362            .scrollbar_width(6.0)
363            .scrollbar_rail_width(6.0)
364            .scrollbar_thumb_width(6.0)
365            .scrollbar_margin(0.0),
366        theme,
367    )
368    .into()
369}
370
371// ---------------------------------------------------------------------------
372// Recursive rendering
373// ---------------------------------------------------------------------------
374
375fn render_node<'a, Message: Clone + 'static>(
376    node: &TreeNode,
377    state: &TreeViewState,
378    on_action: impl Fn(TreeViewAction) -> Message + 'static + Clone,
379    props: TreeViewProps,
380    theme: &Theme,
381    depth: usize,
382    parent_path: &str,
383) -> Element<'a, Message> {
384    let path = if parent_path.is_empty() {
385        node.name().to_string()
386    } else {
387        format!("{parent_path}/{}", node.name())
388    };
389
390    match node {
391        TreeNode::Folder {
392            name,
393            children,
394            icon_open,
395            icon_closed,
396            state: folder_state,
397        } => render_folder(
398            name,
399            children,
400            *icon_open,
401            *icon_closed,
402            *folder_state,
403            &path,
404            state,
405            on_action,
406            props,
407            theme,
408            depth,
409        ),
410        TreeNode::File { name, icon } => {
411            render_file(name, *icon, &path, state, on_action, props, theme, depth)
412        }
413    }
414}
415
416#[allow(clippy::too_many_arguments)]
417fn render_folder<'a, Message: Clone + 'static>(
418    name: &str,
419    children: &[TreeNode],
420    icon_open: Option<LucideIcon>,
421    icon_closed: Option<LucideIcon>,
422    folder_state: FolderState,
423    path: &str,
424    state: &TreeViewState,
425    on_action: impl Fn(TreeViewAction) -> Message + 'static + Clone,
426    props: TreeViewProps,
427    theme: &Theme,
428    depth: usize,
429) -> Element<'a, Message> {
430    let open = state.is_open(path);
431    let left_pad = props.content_offset + props.indent * depth as f32;
432    let fg = theme.palette.foreground;
433    let muted_fg = theme.palette.muted_foreground;
434    let border_color = theme.palette.border;
435    let hover_bg = theme.palette.accent;
436    let hover_fg = theme.palette.accent_foreground;
437    let row_radius = theme.radius.sm;
438
439    let is_loading = folder_state == FolderState::Loading;
440
441    let icon = if is_loading {
442        LucideIcon::Loader
443    } else if open {
444        icon_open.unwrap_or(LucideIcon::FolderOpen)
445    } else {
446        icon_closed.unwrap_or(LucideIcon::Folder)
447    };
448
449    let path_owned = path.to_string();
450    let name_owned = name.to_string();
451    let on_action_clone = on_action.clone();
452
453    // The dependency tuple includes everything that could change the styling or layout of this specific button.
454    let dep = (path_owned.clone(), open, folder_state);
455
456    let trigger_btn = lazy(
457        dep,
458        move |(path_dep, open_dep, state_dep)| -> Element<'static, Message> {
459            let icon_el: Element<'static, Message> = text(char::from(icon).to_string())
460                .font(Font::with_name("lucide"))
461                .size(props.icon_size)
462                .color(muted_fg)
463                .into();
464
465            let label = text(truncate_ellipsis(&name_owned, props.max_label_chars))
466                .size(props.font_size)
467                .color(fg)
468                .wrapping(text::Wrapping::None);
469
470            let trigger_row = row![icon_el, label]
471                .spacing(6)
472                .align_y(Vertical::Center)
473                .width(Length::Fill);
474
475            let btn = iced_button(
476                container(trigger_row)
477                    .padding(Padding {
478                        top: 0.0,
479                        right: 0.0,
480                        bottom: 0.0,
481                        left: left_pad,
482                    })
483                    .height(Length::Fixed(props.row_height))
484                    .width(Length::Fill)
485                    .clip(true)
486                    .align_y(Vertical::Center),
487            )
488            .padding(0)
489            .width(Length::Fill)
490            .style(move |_theme, status| {
491                let bg = match status {
492                    iced_button::Status::Hovered => Background::Color(hover_bg),
493                    _ => Background::Color(Color::TRANSPARENT),
494                };
495                iced_button::Style {
496                    background: Some(bg),
497                    text_color: if matches!(status, iced_button::Status::Hovered) {
498                        hover_fg
499                    } else {
500                        fg
501                    },
502                    border: Border {
503                        radius: row_radius.into(),
504                        ..Border::default()
505                    },
506                    shadow: Default::default(),
507                    snap: true,
508                }
509            });
510
511            // Determine action based on state
512            let action = if *state_dep == FolderState::Unloaded && !*open_dep {
513                TreeViewAction::LoadFolder(path_dep.clone())
514            } else {
515                TreeViewAction::ToggleFolder(path_dep.clone())
516            };
517
518            btn.on_press((on_action_clone)(action)).into()
519        },
520    );
521
522    let mut col = column![
523        container(trigger_btn)
524            .padding(Padding::from([0.0, 4.0]))
525            .width(Length::Fill)
526    ]
527    .spacing(0);
528
529    // Only render children if the folder is both OPEN and has children.
530    if open && !children.is_empty() {
531        let mut children_col = column![].spacing(0).width(Length::Fill);
532        for child in children {
533            children_col = children_col.push(render_node(
534                child,
535                state,
536                on_action.clone(),
537                props,
538                theme,
539                depth + 1,
540                path,
541            ));
542        }
543
544        // Vertical guide line at the folder icon center
545        let guide_x = left_pad + props.icon_size * 0.5;
546
547        let guide_line = rule::vertical(1).style(move |_theme| rule::Style {
548            color: border_color,
549            radius: 0.0.into(),
550            fill_mode: rule::FillMode::Full,
551            snap: true,
552        });
553
554        // Guide layer: Space pushes the line to the right x-position, Fill height
555        let guide_layer = row![Space::new().width(guide_x), guide_line]
556            .spacing(0)
557            .height(Length::Fill);
558
559        // Stack: children_col first (determines size), guide overlaid on top
560        let children_with_guide = stack![children_col, guide_layer].width(Length::Fill);
561
562        col = col.push(children_with_guide);
563    }
564
565    col.into()
566}
567
568#[allow(clippy::too_many_arguments)]
569fn render_file<'a, Message: Clone + 'static>(
570    name: &str,
571    icon: Option<LucideIcon>,
572    path: &str,
573    state: &TreeViewState,
574    on_action: impl Fn(TreeViewAction) -> Message + 'static + Clone,
575    props: TreeViewProps,
576    theme: &Theme,
577    depth: usize,
578) -> Element<'a, Message> {
579    let left_pad = props.content_offset + props.indent * depth as f32 + 3.0;
580    let fg = theme.palette.foreground;
581    let muted_fg = theme.palette.muted_foreground;
582    let accent = theme.palette.accent;
583    let accent_fg = theme.palette.accent_foreground;
584    let hover_bg = theme.palette.accent;
585    let row_radius = theme.radius.sm;
586    let is_selected = state.is_selected(path);
587
588    let path_owned = path.to_string();
589    let name_owned = name.to_string();
590    let icon_owned = icon;
591    let on_action_clone = on_action.clone();
592
593    let dep = (path_owned.clone(), is_selected);
594
595    let file_btn = lazy(
596        dep,
597        move |(path_dep, _is_selected_dep)| -> Element<'static, Message> {
598            let icon_el: Element<'static, Message> =
599                text(char::from(icon_owned.unwrap_or(LucideIcon::File)).to_string())
600                    .font(Font::with_name("lucide"))
601                    .size(props.icon_size)
602                    .color(if is_selected { accent_fg } else { muted_fg })
603                    .into();
604
605            let label_color = if is_selected { accent_fg } else { fg };
606            let label = text(truncate_ellipsis(&name_owned, props.max_label_chars))
607                .size(props.font_size)
608                .color(label_color)
609                .wrapping(text::Wrapping::None);
610
611            let content_row = row![icon_el, label]
612                .spacing(6)
613                .align_y(Vertical::Center)
614                .width(Length::Fill);
615
616            let mut btn = iced_button(
617                container(content_row)
618                    .padding(Padding {
619                        top: 0.0,
620                        right: 0.0,
621                        bottom: 0.0,
622                        left: left_pad,
623                    })
624                    .height(Length::Fixed(props.row_height))
625                    .width(Length::Fill)
626                    .clip(true)
627                    .align_y(Vertical::Center),
628            )
629            .padding(0)
630            .width(Length::Fill)
631            .style(move |_theme, status| {
632                let (bg, txt) = if is_selected {
633                    (Background::Color(accent), accent_fg)
634                } else {
635                    match status {
636                        iced_button::Status::Hovered => (Background::Color(hover_bg), fg),
637                        _ => (Background::Color(Color::TRANSPARENT), fg),
638                    }
639                };
640                iced_button::Style {
641                    background: Some(bg),
642                    text_color: txt,
643                    border: Border {
644                        radius: row_radius.into(),
645                        ..Border::default()
646                    },
647                    shadow: Default::default(),
648                    snap: true,
649                }
650            });
651
652            if props.selectable {
653                btn = btn.on_press((on_action_clone)(TreeViewAction::SelectFile(
654                    path_dep.clone(),
655                )));
656            }
657
658            btn.into()
659        },
660    );
661
662    container(file_btn)
663        .padding(Padding::from([0.0, 4.0]))
664        .width(Length::Fill)
665        .into()
666}