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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum FolderState {
16 Unloaded,
18 Loading,
20 Loaded,
22}
23
24#[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 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 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 pub fn file(name: impl Into<String>) -> Self {
66 Self::File {
67 name: name.into(),
68 icon: None,
69 }
70 }
71
72 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 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 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#[derive(Clone, Debug, Default)]
119pub struct TreeViewState {
120 pub open_folders: Vec<String>,
122 pub selected: Option<String>,
124}
125
126impl TreeViewState {
127 pub fn new() -> Self {
128 Self::default()
129 }
130
131 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 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#[derive(Clone, Copy, Debug)]
195pub struct TreeViewProps {
196 pub indent: f32,
198 pub icon_size: f32,
200 pub font_size: f32,
202 pub row_height: f32,
204 pub selectable: bool,
206 pub max_label_chars: usize,
208 pub content_offset: f32,
210 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
277pub enum TreeScrollbarVisibility {
278 Auto,
280 Visible,
282 Hidden,
284}
285
286#[derive(Clone, Debug)]
292pub enum TreeViewAction {
293 ToggleFolder(String),
295 SelectFile(String),
297 LoadFolder(String),
299}
300
301fn 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
316pub 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
371fn 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 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 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 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 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 let guide_layer = row![Space::new().width(guide_x), guide_line]
556 .spacing(0)
557 .height(Length::Fill);
558
559 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}