Skip to main content

rich_rust/renderables/
tree.rs

1//! Tree renderable.
2//!
3//! This module provides tree components for displaying hierarchical data
4//! in the terminal with configurable guide characters and styles.
5
6use crate::console::{Console, ConsoleOptions};
7use crate::renderables::Renderable;
8use crate::segment::Segment;
9use crate::style::Style;
10use crate::text::Text;
11
12/// Guide character styles for tree rendering.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum TreeGuides {
15    /// ASCII guides using `|`, `-`, and related characters.
16    Ascii,
17    /// Unicode box-drawing characters (default).
18    #[default]
19    Unicode,
20    /// Bold Unicode box-drawing characters.
21    Bold,
22    /// Double-line Unicode characters.
23    Double,
24    /// Rounded Unicode characters.
25    Rounded,
26}
27
28impl TreeGuides {
29    /// Vertical continuation guide (for items that have siblings below).
30    #[must_use]
31    pub const fn vertical(&self) -> &str {
32        match self {
33            Self::Ascii => "|   ",
34            Self::Unicode | Self::Rounded => "\u{2502}   ", // │
35            Self::Bold => "\u{2503}   ",                    // ┃
36            Self::Double => "\u{2551}   ",                  // ║
37        }
38    }
39
40    /// Branch guide (for items with siblings below).
41    #[must_use]
42    pub const fn branch(&self) -> &str {
43        match self {
44            Self::Ascii => "+-- ",
45            Self::Unicode => "\u{251C}\u{2500}\u{2500} ", // ├──
46            Self::Bold => "\u{2523}\u{2501}\u{2501} ",    // ┣━━
47            Self::Double => "\u{2560}\u{2550}\u{2550} ",  // ╠══
48            Self::Rounded => "\u{251C}\u{2500}\u{2500} ", // ├──
49        }
50    }
51
52    /// Last item guide (for items without siblings below).
53    #[must_use]
54    pub const fn last(&self) -> &str {
55        match self {
56            Self::Ascii => "`-- ",
57            Self::Unicode => "\u{2514}\u{2500}\u{2500} ", // └──
58            Self::Bold => "\u{2517}\u{2501}\u{2501} ",    // ┗━━
59            Self::Double => "\u{255A}\u{2550}\u{2550} ",  // ╚══
60            Self::Rounded => "\u{2570}\u{2500}\u{2500} ", // ╰──
61        }
62    }
63
64    /// Empty space (for indentation where no guide is needed).
65    #[must_use]
66    pub const fn space(&self) -> &'static str {
67        "    "
68    }
69}
70
71/// A node in the tree.
72#[derive(Debug, Clone)]
73pub struct TreeNode {
74    /// The label for this node.
75    label: Text,
76    /// Child nodes.
77    children: Vec<TreeNode>,
78    /// Whether this node is expanded (children visible).
79    expanded: bool,
80    /// Optional icon to display before the label.
81    icon: Option<String>,
82    /// Style for the icon.
83    icon_style: Style,
84}
85
86impl TreeNode {
87    /// Create a new tree node with a label.
88    ///
89    /// Passing a `&str` uses `Text::new()` and does **NOT** parse markup.
90    /// For styled labels, pass a pre-styled `Text` (e.g. from
91    /// [`crate::markup::render_or_plain`]).
92    #[must_use]
93    pub fn new(label: impl Into<Text>) -> Self {
94        Self {
95            label: label.into(),
96            children: Vec::new(),
97            expanded: true,
98            icon: None,
99            icon_style: Style::new(),
100        }
101    }
102
103    /// Create a new tree node with an icon and label.
104    ///
105    /// Passing a `&str` uses `Text::new()` and does **NOT** parse markup.
106    /// For styled labels, pass a pre-styled `Text` (e.g. from
107    /// [`crate::markup::render_or_plain`]).
108    #[must_use]
109    pub fn with_icon(icon: impl Into<String>, label: impl Into<Text>) -> Self {
110        Self {
111            label: label.into(),
112            children: Vec::new(),
113            expanded: true,
114            icon: Some(icon.into()),
115            icon_style: Style::new(),
116        }
117    }
118
119    /// Add a child node.
120    #[must_use]
121    pub fn child(mut self, node: TreeNode) -> Self {
122        self.children.push(node);
123        self
124    }
125
126    /// Add multiple child nodes.
127    #[must_use]
128    pub fn children(mut self, nodes: impl IntoIterator<Item = TreeNode>) -> Self {
129        self.children.extend(nodes);
130        self
131    }
132
133    /// Set the icon for this node.
134    #[must_use]
135    pub fn icon(mut self, icon: impl Into<String>) -> Self {
136        self.icon = Some(icon.into());
137        self
138    }
139
140    /// Set the icon style.
141    #[must_use]
142    pub fn icon_style(mut self, style: Style) -> Self {
143        self.icon_style = style;
144        self
145    }
146
147    /// Set whether this node is expanded.
148    #[must_use]
149    pub fn expanded(mut self, expanded: bool) -> Self {
150        self.expanded = expanded;
151        self
152    }
153
154    /// Collapse this node (hide children).
155    #[must_use]
156    pub fn collapsed(self) -> Self {
157        self.expanded(false)
158    }
159
160    /// Get the label text.
161    #[must_use]
162    pub fn label(&self) -> &Text {
163        &self.label
164    }
165
166    /// Get the children.
167    #[must_use]
168    pub fn children_nodes(&self) -> &[TreeNode] {
169        &self.children
170    }
171
172    /// Check if this node has children.
173    #[must_use]
174    pub fn has_children(&self) -> bool {
175        !self.children.is_empty()
176    }
177
178    /// Check if this node is expanded.
179    #[must_use]
180    pub fn is_expanded(&self) -> bool {
181        self.expanded
182    }
183
184    /// Get the icon if set.
185    #[must_use]
186    pub fn get_icon(&self) -> Option<&str> {
187        self.icon.as_deref()
188    }
189}
190
191/// A tree for displaying hierarchical data.
192#[derive(Debug, Clone)]
193pub struct Tree {
194    /// The root node.
195    root: TreeNode,
196    /// Guide style.
197    guides: TreeGuides,
198    /// Style for the guide characters.
199    guide_style: Style,
200    /// Whether to show the root node.
201    show_root: bool,
202    /// Style for highlighted nodes.
203    highlight_style: Option<Style>,
204    /// Maximum depth to display (-1 for unlimited).
205    max_depth: isize,
206}
207
208impl Default for Tree {
209    fn default() -> Self {
210        Self {
211            root: TreeNode::new("root"),
212            guides: TreeGuides::default(),
213            guide_style: Style::new(),
214            show_root: true,
215            highlight_style: None,
216            max_depth: -1,
217        }
218    }
219}
220
221impl Tree {
222    /// Create a new tree with the given root node.
223    #[must_use]
224    pub fn new(root: TreeNode) -> Self {
225        Self {
226            root,
227            ..Self::default()
228        }
229    }
230
231    /// Create a new tree with just a label for the root.
232    ///
233    /// Passing a `&str` uses `Text::new()` and does **NOT** parse markup.
234    /// For styled labels, pass a pre-styled `Text` (e.g. from
235    /// [`crate::markup::render_or_plain`]).
236    #[must_use]
237    pub fn with_label(label: impl Into<Text>) -> Self {
238        Self::new(TreeNode::new(label))
239    }
240
241    /// Set the guide style.
242    #[must_use]
243    pub fn guides(mut self, guides: TreeGuides) -> Self {
244        self.guides = guides;
245        self
246    }
247
248    /// Set the style for guide characters.
249    #[must_use]
250    pub fn guide_style(mut self, style: Style) -> Self {
251        self.guide_style = style;
252        self
253    }
254
255    /// Set whether to show the root node.
256    #[must_use]
257    pub fn show_root(mut self, show: bool) -> Self {
258        self.show_root = show;
259        self
260    }
261
262    /// Hide the root node (only show children).
263    #[must_use]
264    pub fn hide_root(self) -> Self {
265        self.show_root(false)
266    }
267
268    /// Set the highlight style for nodes.
269    #[must_use]
270    pub fn highlight_style(mut self, style: Style) -> Self {
271        self.highlight_style = Some(style);
272        self
273    }
274
275    /// Set the maximum depth to display.
276    #[must_use]
277    pub fn max_depth(mut self, depth: isize) -> Self {
278        self.max_depth = depth;
279        self
280    }
281
282    /// Add a child node to the root.
283    #[must_use]
284    pub fn child(mut self, node: TreeNode) -> Self {
285        self.root.children.push(node);
286        self
287    }
288
289    /// Add multiple children to the root.
290    #[must_use]
291    pub fn children(mut self, nodes: impl IntoIterator<Item = TreeNode>) -> Self {
292        self.root.children.extend(nodes);
293        self
294    }
295
296    /// Render the tree to segments.
297    #[must_use]
298    pub fn render(&self) -> Vec<Segment<'_>> {
299        let mut segments = Vec::new();
300        let prefix_stack: Vec<bool> = Vec::new();
301
302        if self.show_root {
303            self.render_node(&self.root, &mut segments, &prefix_stack, true, 0);
304        } else {
305            // Render children directly
306            let children = &self.root.children;
307            for (i, child) in children.iter().enumerate() {
308                let is_last = i == children.len() - 1;
309                self.render_node(child, &mut segments, &prefix_stack, is_last, 0);
310            }
311        }
312
313        segments
314    }
315
316    fn sanitize_label(label: &Text) -> Text {
317        if !label.plain().contains('\n') {
318            return label.clone();
319        }
320
321        let mut sanitized = Text::new(label.plain().replace('\n', " "));
322        sanitized.set_style(label.style().clone());
323        sanitized.justify = label.justify;
324        sanitized.overflow = label.overflow;
325        sanitized.no_wrap = label.no_wrap;
326        sanitized.end.clone_from(&label.end);
327        sanitized.tab_size = label.tab_size;
328        for span in label.spans() {
329            sanitized.stylize(span.start, span.end, span.style.clone());
330        }
331        sanitized
332    }
333
334    /// Render a single node and its children recursively.
335    #[expect(
336        clippy::cast_possible_wrap,
337        reason = "tree depth will never exceed isize::MAX"
338    )]
339    fn render_node<'a>(
340        &'a self,
341        node: &'a TreeNode,
342        segments: &mut Vec<Segment<'a>>,
343        prefix_stack: &[bool],
344        is_last: bool,
345        depth: usize,
346    ) {
347        // Check depth limit
348        if self.max_depth >= 0 && depth as isize > self.max_depth {
349            return;
350        }
351
352        // Build the prefix (guides from ancestors)
353        for &has_more_siblings in prefix_stack {
354            let guide = if has_more_siblings {
355                self.guides.vertical()
356            } else {
357                self.guides.space()
358            };
359            segments.push(Segment::new(guide, Some(self.guide_style.clone())));
360        }
361
362        // Add the branch guide for this node (if not root at depth 0)
363        if depth > 0 || !self.show_root {
364            let guide = if is_last {
365                self.guides.last()
366            } else {
367                self.guides.branch()
368            };
369            segments.push(Segment::new(guide, Some(self.guide_style.clone())));
370        }
371
372        // Add icon if present
373        if let Some(icon) = node.get_icon() {
374            segments.push(Segment::new(
375                format!("{icon} "),
376                Some(node.icon_style.clone()),
377            ));
378        }
379
380        // Sanitize label newlines to avoid broken tree line structure.
381        let label_text = Self::sanitize_label(&node.label);
382
383        let mut label_segments: Vec<Segment<'static>> = label_text
384            .render("")
385            .into_iter()
386            .map(Segment::into_owned)
387            .collect();
388        if let Some(ref highlight) = self.highlight_style {
389            for segment in &mut label_segments {
390                if !segment.is_control() {
391                    segment.style = Some(match segment.style.take() {
392                        Some(existing) => existing.combine(highlight),
393                        None => highlight.clone(),
394                    });
395                }
396            }
397        }
398        for segment in label_segments {
399            segments.push(segment);
400        }
401
402        // Add collapse indicator if has children but collapsed
403        if node.has_children() && !node.is_expanded() {
404            segments.push(Segment::new(" [...]", Some(self.guide_style.clone())));
405        }
406
407        segments.push(Segment::line());
408
409        // Render children if expanded
410        if node.is_expanded() {
411            let children = &node.children;
412            let mut new_prefix_stack = prefix_stack.to_vec();
413            if !(self.show_root && depth == 0) {
414                new_prefix_stack.push(!is_last);
415            }
416
417            for (i, child) in children.iter().enumerate() {
418                let child_is_last = i == children.len() - 1;
419                self.render_node(child, segments, &new_prefix_stack, child_is_last, depth + 1);
420            }
421        }
422    }
423
424    /// Render the tree as a plain string.
425    #[must_use]
426    pub fn render_plain(&self) -> String {
427        self.render()
428            .into_iter()
429            .map(|seg| seg.text.into_owned())
430            .collect()
431    }
432}
433
434impl Renderable for Tree {
435    fn render<'a>(&'a self, _console: &Console, _options: &ConsoleOptions) -> Vec<Segment<'a>> {
436        self.render()
437    }
438}
439
440/// Create a tree from a file system-like structure.
441///
442/// Takes a root path and builds a tree showing the directory structure.
443#[must_use]
444pub fn file_tree(root: &str, entries: &[(&str, bool)]) -> Tree {
445    let mut root_node = TreeNode::with_icon("📁", root);
446
447    for (path, is_dir) in entries {
448        let icon = if *is_dir { "📁" } else { "📄" };
449        root_node = root_node.child(TreeNode::with_icon(icon, *path));
450    }
451
452    Tree::new(root_node)
453}
454
455/// Create an ASCII-style tree.
456#[must_use]
457pub fn ascii_tree(root: TreeNode) -> Tree {
458    Tree::new(root).guides(TreeGuides::Ascii)
459}
460
461/// Create a rounded-style tree.
462#[must_use]
463pub fn rounded_tree(root: TreeNode) -> Tree {
464    Tree::new(root).guides(TreeGuides::Rounded)
465}
466
467/// Create a bold-style tree.
468#[must_use]
469pub fn bold_tree(root: TreeNode) -> Tree {
470    Tree::new(root).guides(TreeGuides::Bold)
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_tree_node_new() {
479        let node = TreeNode::new("test");
480        assert_eq!(node.label().plain(), "test");
481        assert!(node.children_nodes().is_empty());
482        assert!(node.is_expanded());
483    }
484
485    #[test]
486    fn test_tree_node_with_icon() {
487        let node = TreeNode::with_icon("📁", "folder");
488        assert_eq!(node.label().plain(), "folder");
489        assert_eq!(node.get_icon(), Some("📁"));
490    }
491
492    #[test]
493    fn test_tree_node_children() {
494        let node = TreeNode::new("root")
495            .child(TreeNode::new("child1"))
496            .child(TreeNode::new("child2"));
497        assert_eq!(node.children_nodes().len(), 2);
498        assert!(node.has_children());
499    }
500
501    #[test]
502    fn test_tree_node_collapsed() {
503        let node = TreeNode::new("test").collapsed();
504        assert!(!node.is_expanded());
505    }
506
507    #[test]
508    fn test_tree_new() {
509        let tree = Tree::with_label("root");
510        assert!(tree.show_root);
511        assert_eq!(tree.guides, TreeGuides::Unicode);
512    }
513
514    #[test]
515    fn test_tree_guides_ascii() {
516        let guides = TreeGuides::Ascii;
517        assert_eq!(guides.vertical(), "|   ");
518        assert_eq!(guides.branch(), "+-- ");
519        assert_eq!(guides.last(), "`-- ");
520        assert_eq!(guides.space(), "    ");
521    }
522
523    #[test]
524    fn test_tree_guides_unicode() {
525        let guides = TreeGuides::Unicode;
526        assert!(guides.vertical().starts_with('\u{2502}')); // │
527        assert!(guides.branch().starts_with('\u{251C}')); // ├
528        assert!(guides.last().starts_with('\u{2514}')); // └
529    }
530
531    #[test]
532    fn test_tree_render_simple() {
533        let tree = Tree::with_label("root")
534            .child(TreeNode::new("child1"))
535            .child(TreeNode::new("child2"));
536
537        let segments = tree.render();
538        assert!(!segments.is_empty());
539
540        let plain = tree.render_plain();
541        assert!(plain.contains("root"));
542        assert!(plain.contains("child1"));
543        assert!(plain.contains("child2"));
544    }
545
546    #[test]
547    fn test_tree_render_preserves_spans() {
548        use crate::style::Attributes;
549
550        let mut label = Text::new("root");
551        label.stylize(0, 4, Style::new().bold());
552        let tree = Tree::new(TreeNode::new(label));
553
554        let segments = tree.render();
555        let has_bold = segments.iter().any(|seg| {
556            seg.text.contains("root")
557                && seg
558                    .style
559                    .as_ref()
560                    .is_some_and(|style| style.attributes.contains(Attributes::BOLD))
561        });
562
563        assert!(has_bold);
564    }
565
566    #[test]
567    fn test_tree_render_preserves_spans_after_newline_sanitization() {
568        use crate::style::Attributes;
569
570        let mut label = Text::new("root\nnode");
571        label.stylize_all(Style::new().bold());
572        label.stylize(5, 9, Style::new().italic());
573        let tree = Tree::new(TreeNode::new(label));
574
575        let rendered = tree.render_plain();
576        assert!(rendered.contains("root node"));
577        assert!(!rendered.contains("root\nnode"));
578
579        let segments = tree.render();
580        let has_italic_node = segments.iter().any(|seg| {
581            seg.text.contains("node")
582                && seg
583                    .style
584                    .as_ref()
585                    .is_some_and(|style| style.attributes.contains(Attributes::ITALIC))
586        });
587        assert!(has_italic_node);
588    }
589
590    #[test]
591    fn test_tree_render_nested() {
592        let tree =
593            Tree::with_label("root").child(TreeNode::new("parent").child(TreeNode::new("child")));
594
595        let plain = tree.render_plain();
596        assert!(plain.contains("root"));
597        assert!(plain.contains("parent"));
598        assert!(plain.contains("child"));
599    }
600
601    #[test]
602    fn test_tree_hide_root() {
603        let tree = Tree::with_label("root")
604            .hide_root()
605            .child(TreeNode::new("visible"));
606
607        let plain = tree.render_plain();
608        assert!(!plain.contains("root"));
609        assert!(plain.contains("visible"));
610    }
611
612    #[test]
613    fn test_tree_collapsed_node() {
614        let tree = Tree::with_label("root").child(
615            TreeNode::new("collapsed")
616                .collapsed()
617                .child(TreeNode::new("hidden")),
618        );
619
620        let plain = tree.render_plain();
621        assert!(plain.contains("collapsed"));
622        assert!(plain.contains("[...]"));
623        assert!(!plain.contains("hidden"));
624    }
625
626    #[test]
627    fn test_tree_max_depth() {
628        let tree = Tree::with_label("root")
629            .max_depth(1)
630            .child(TreeNode::new("level1").child(TreeNode::new("level2")));
631
632        let plain = tree.render_plain();
633        assert!(plain.contains("root"));
634        assert!(plain.contains("level1"));
635        assert!(!plain.contains("level2"));
636    }
637
638    #[test]
639    fn test_tree_ascii_style() {
640        let tree = ascii_tree(TreeNode::new("root").child(TreeNode::new("child")));
641
642        let plain = tree.render_plain();
643        assert!(plain.contains("+--") || plain.contains("`--"));
644    }
645
646    #[test]
647    fn test_tree_with_icons() {
648        let tree = Tree::with_label("project")
649            .child(TreeNode::with_icon("📁", "src"))
650            .child(TreeNode::with_icon("📄", "README.md"));
651
652        let plain = tree.render_plain();
653        assert!(plain.contains("📁"));
654        assert!(plain.contains("📄"));
655        assert!(plain.contains("src"));
656        assert!(plain.contains("README.md"));
657    }
658
659    #[test]
660    fn test_file_tree() {
661        let tree = file_tree("project", &[("src", true), ("Cargo.toml", false)]);
662
663        let plain = tree.render_plain();
664        assert!(plain.contains("project"));
665        assert!(plain.contains("src"));
666        assert!(plain.contains("Cargo.toml"));
667    }
668
669    #[test]
670    fn test_tree_complex_structure() {
671        let tree = Tree::with_label("root")
672            .child(
673                TreeNode::new("branch1")
674                    .child(TreeNode::new("leaf1"))
675                    .child(TreeNode::new("leaf2")),
676            )
677            .child(
678                TreeNode::new("branch2")
679                    .child(TreeNode::new("sub-branch").child(TreeNode::new("deep-leaf"))),
680            )
681            .child(TreeNode::new("leaf3"));
682
683        let plain = tree.render_plain();
684
685        // Verify all nodes are present
686        assert!(plain.contains("root"));
687        assert!(plain.contains("branch1"));
688        assert!(plain.contains("branch2"));
689        assert!(plain.contains("leaf1"));
690        assert!(plain.contains("leaf2"));
691        assert!(plain.contains("leaf3"));
692        assert!(plain.contains("sub-branch"));
693        assert!(plain.contains("deep-leaf"));
694    }
695
696    #[test]
697    fn test_tree_empty_root() {
698        // Tree with just an empty root
699        let tree = Tree::with_label("");
700        let plain = tree.render_plain();
701        // Should render without panic
702        // Just verify it doesn't panic - the test passing is proof enough
703        let _ = plain;
704    }
705
706    #[test]
707    fn test_tree_single_node() {
708        let tree = Tree::with_label("single");
709        let plain = tree.render_plain();
710        assert!(plain.contains("single"));
711        // Should have no guide characters at root
712        assert!(!plain.contains("├──"));
713        assert!(!plain.contains("└──"));
714    }
715
716    #[test]
717    fn test_tree_wide_unicode_labels() {
718        // Test with CJK characters (each is 2 cells wide)
719        let tree = Tree::with_label("项目") // "project" in Chinese
720            .child(TreeNode::new("源代码")) // "source code"
721            .child(TreeNode::new("文档")); // "documentation"
722
723        let plain = tree.render_plain();
724        assert!(plain.contains("项目"));
725        assert!(plain.contains("源代码"));
726        assert!(plain.contains("文档"));
727    }
728
729    #[test]
730    fn test_tree_emoji_labels() {
731        let tree = Tree::with_label("📁 Root")
732            .child(TreeNode::new("📄 File"))
733            .child(TreeNode::new("🔧 Config"));
734
735        let plain = tree.render_plain();
736        assert!(plain.contains("📁"));
737        assert!(plain.contains("📄"));
738        assert!(plain.contains("🔧"));
739    }
740
741    #[test]
742    fn test_tree_guides_bold() {
743        let guides = TreeGuides::Bold;
744        assert_eq!(guides.vertical(), "┃   ");
745        assert_eq!(guides.branch(), "┣━━ ");
746        assert_eq!(guides.last(), "┗━━ ");
747        assert_eq!(guides.space(), "    ");
748    }
749
750    #[test]
751    fn test_tree_guides_double() {
752        let guides = TreeGuides::Double;
753        assert_eq!(guides.vertical(), "║   ");
754        assert_eq!(guides.branch(), "╠══ ");
755        assert_eq!(guides.last(), "╚══ ");
756        assert_eq!(guides.space(), "    ");
757    }
758
759    #[test]
760    fn test_tree_guides_rounded() {
761        let guides = TreeGuides::Rounded;
762        assert_eq!(guides.vertical(), "│   ");
763        assert_eq!(guides.branch(), "├── ");
764        assert_eq!(guides.last(), "╰── "); // Rounded uses ╰
765        assert_eq!(guides.space(), "    ");
766    }
767}