Skip to main content

ftui_widgets/
tree.rs

1//! Tree widget for hierarchical display.
2//!
3//! Renders a tree of labeled nodes with configurable guide characters
4//! and styles, suitable for file trees or structured views.
5//!
6//! # Example
7//!
8//! ```
9//! use ftui_widgets::tree::{Tree, TreeNode, TreeGuides};
10//!
11//! let tree = Tree::new(TreeNode::new("root")
12//!     .child(TreeNode::new("src")
13//!         .child(TreeNode::new("main.rs"))
14//!         .child(TreeNode::new("lib.rs")))
15//!     .child(TreeNode::new("Cargo.toml")));
16//!
17//! assert_eq!(tree.root().label(), "root");
18//! assert_eq!(tree.root().children().len(), 2);
19//! ```
20
21use crate::stateful::Stateful;
22use crate::undo_support::{TreeUndoExt, UndoSupport, UndoWidgetId};
23use crate::{Widget, draw_text_span};
24use ftui_core::geometry::Rect;
25use ftui_render::frame::{Frame, HitId, HitRegion};
26use ftui_style::Style;
27use std::any::Any;
28use std::collections::HashSet;
29
30/// Guide character styles for tree rendering.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum TreeGuides {
33    /// ASCII guides: `|`, `+--`, `` `-- ``.
34    Ascii,
35    /// Unicode box-drawing characters (default).
36    #[default]
37    Unicode,
38    /// Bold Unicode box-drawing characters.
39    Bold,
40    /// Double-line Unicode characters.
41    Double,
42    /// Rounded Unicode characters.
43    Rounded,
44}
45
46impl TreeGuides {
47    /// Vertical continuation (item has siblings below).
48    #[must_use]
49    pub const fn vertical(&self) -> &str {
50        match self {
51            Self::Ascii => "|   ",
52            Self::Unicode | Self::Rounded => "\u{2502}   ",
53            Self::Bold => "\u{2503}   ",
54            Self::Double => "\u{2551}   ",
55        }
56    }
57
58    /// Branch guide (item has siblings below).
59    #[must_use]
60    pub const fn branch(&self) -> &str {
61        match self {
62            Self::Ascii => "+-- ",
63            Self::Unicode => "\u{251C}\u{2500}\u{2500} ",
64            Self::Bold => "\u{2523}\u{2501}\u{2501} ",
65            Self::Double => "\u{2560}\u{2550}\u{2550} ",
66            Self::Rounded => "\u{251C}\u{2500}\u{2500} ",
67        }
68    }
69
70    /// Last-item guide (no siblings below).
71    #[must_use]
72    pub const fn last(&self) -> &str {
73        match self {
74            Self::Ascii => "`-- ",
75            Self::Unicode => "\u{2514}\u{2500}\u{2500} ",
76            Self::Bold => "\u{2517}\u{2501}\u{2501} ",
77            Self::Double => "\u{255A}\u{2550}\u{2550} ",
78            Self::Rounded => "\u{2570}\u{2500}\u{2500} ",
79        }
80    }
81
82    /// Empty indentation (no guide needed).
83    #[must_use]
84    pub const fn space(&self) -> &str {
85        "    "
86    }
87
88    /// Width in columns of each guide segment.
89    #[must_use]
90    pub fn width(&self) -> usize {
91        4
92    }
93}
94
95/// A node in the tree hierarchy.
96#[derive(Debug, Clone)]
97pub struct TreeNode {
98    label: String,
99    /// Child nodes (crate-visible for undo support).
100    pub(crate) children: Vec<TreeNode>,
101    /// Whether this node is expanded (crate-visible for undo support).
102    pub(crate) expanded: bool,
103}
104
105impl TreeNode {
106    /// Create a new tree node with the given label.
107    #[must_use]
108    pub fn new(label: impl Into<String>) -> Self {
109        Self {
110            label: label.into(),
111            children: Vec::new(),
112            expanded: true,
113        }
114    }
115
116    /// Add a child node.
117    #[must_use]
118    pub fn child(mut self, node: TreeNode) -> Self {
119        self.children.push(node);
120        self
121    }
122
123    /// Set children from a vec.
124    #[must_use]
125    pub fn with_children(mut self, nodes: Vec<TreeNode>) -> Self {
126        self.children = nodes;
127        self
128    }
129
130    /// Set whether this node is expanded.
131    #[must_use]
132    pub fn with_expanded(mut self, expanded: bool) -> Self {
133        self.expanded = expanded;
134        self
135    }
136
137    /// Get the label.
138    #[must_use]
139    pub fn label(&self) -> &str {
140        &self.label
141    }
142
143    /// Get the children.
144    #[must_use]
145    pub fn children(&self) -> &[TreeNode] {
146        &self.children
147    }
148
149    /// Whether this node is expanded.
150    #[must_use]
151    pub fn is_expanded(&self) -> bool {
152        self.expanded
153    }
154
155    /// Toggle the expanded state.
156    pub fn toggle_expanded(&mut self) {
157        self.expanded = !self.expanded;
158    }
159
160    /// Count all visible (expanded) nodes, including this one.
161    #[must_use]
162    pub fn visible_count(&self) -> usize {
163        let mut count = 1;
164        if self.expanded {
165            for child in &self.children {
166                count += child.visible_count();
167            }
168        }
169        count
170    }
171
172    /// Collect all expanded node paths into a set.
173    #[allow(dead_code)]
174    pub(crate) fn collect_expanded(&self, prefix: &str, out: &mut HashSet<String>) {
175        let path = if prefix.is_empty() {
176            self.label.clone()
177        } else {
178            format!("{}/{}", prefix, self.label)
179        };
180
181        if self.expanded && !self.children.is_empty() {
182            out.insert(path.clone());
183        }
184
185        for child in &self.children {
186            child.collect_expanded(&path, out);
187        }
188    }
189
190    /// Apply expanded state from a set of paths.
191    #[allow(dead_code)]
192    pub(crate) fn apply_expanded(&mut self, prefix: &str, expanded_paths: &HashSet<String>) {
193        let path = if prefix.is_empty() {
194            self.label.clone()
195        } else {
196            format!("{}/{}", prefix, self.label)
197        };
198
199        if !self.children.is_empty() {
200            self.expanded = expanded_paths.contains(&path);
201        }
202
203        for child in &mut self.children {
204            child.apply_expanded(&path, expanded_paths);
205        }
206    }
207}
208
209/// Tree widget for rendering hierarchical data.
210#[derive(Debug, Clone)]
211pub struct Tree {
212    /// Unique ID for undo tracking.
213    undo_id: UndoWidgetId,
214    root: TreeNode,
215    /// Whether to show the root node.
216    show_root: bool,
217    /// Guide character style.
218    guides: TreeGuides,
219    /// Style for guide characters.
220    guide_style: Style,
221    /// Style for node labels.
222    label_style: Style,
223    /// Style for the root node label.
224    root_style: Style,
225    /// Optional persistence ID for state saving/restoration.
226    persistence_id: Option<String>,
227    /// Optional hit ID for mouse interaction.
228    hit_id: Option<HitId>,
229}
230
231impl Tree {
232    /// Create a tree widget with the given root node.
233    #[must_use]
234    pub fn new(root: TreeNode) -> Self {
235        Self {
236            undo_id: UndoWidgetId::new(),
237            root,
238            show_root: true,
239            guides: TreeGuides::default(),
240            guide_style: Style::default(),
241            label_style: Style::default(),
242            root_style: Style::default(),
243            persistence_id: None,
244            hit_id: None,
245        }
246    }
247
248    /// Set whether to show the root node.
249    #[must_use]
250    pub fn with_show_root(mut self, show: bool) -> Self {
251        self.show_root = show;
252        self
253    }
254
255    /// Set the guide character style.
256    #[must_use]
257    pub fn with_guides(mut self, guides: TreeGuides) -> Self {
258        self.guides = guides;
259        self
260    }
261
262    /// Set the style for guide characters.
263    #[must_use]
264    pub fn with_guide_style(mut self, style: Style) -> Self {
265        self.guide_style = style;
266        self
267    }
268
269    /// Set the style for node labels.
270    #[must_use]
271    pub fn with_label_style(mut self, style: Style) -> Self {
272        self.label_style = style;
273        self
274    }
275
276    /// Set the style for the root label.
277    #[must_use]
278    pub fn with_root_style(mut self, style: Style) -> Self {
279        self.root_style = style;
280        self
281    }
282
283    /// Set a persistence ID for state saving.
284    #[must_use]
285    pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
286        self.persistence_id = Some(id.into());
287        self
288    }
289
290    /// Get the persistence ID, if set.
291    #[must_use]
292    pub fn persistence_id(&self) -> Option<&str> {
293        self.persistence_id.as_deref()
294    }
295
296    /// Set a hit ID for mouse interaction.
297    #[must_use]
298    pub fn hit_id(mut self, id: HitId) -> Self {
299        self.hit_id = Some(id);
300        self
301    }
302
303    /// Get a reference to the root node.
304    #[must_use]
305    pub fn root(&self) -> &TreeNode {
306        &self.root
307    }
308
309    /// Get a mutable reference to the root node.
310    pub fn root_mut(&mut self) -> &mut TreeNode {
311        &mut self.root
312    }
313
314    #[allow(clippy::too_many_arguments)]
315    fn render_node(
316        &self,
317        node: &TreeNode,
318        depth: usize,
319        is_last: &mut Vec<bool>,
320        area: Rect,
321        frame: &mut Frame,
322        current_row: &mut usize,
323        deg: ftui_render::budget::DegradationLevel,
324    ) {
325        if *current_row >= area.height as usize {
326            return;
327        }
328
329        let y = area.y.saturating_add(*current_row as u16);
330        let mut x = area.x;
331        let max_x = area.right();
332
333        // Draw guide characters for each depth level
334        if depth > 0 && deg.apply_styling() {
335            for d in 0..depth {
336                let is_last_at_depth = is_last.get(d).copied().unwrap_or(false);
337                let guide = if d == depth - 1 {
338                    // This is the immediate parent level
339                    if is_last_at_depth {
340                        self.guides.last()
341                    } else {
342                        self.guides.branch()
343                    }
344                } else {
345                    // Ancestor level: show vertical line or blank
346                    if is_last_at_depth {
347                        self.guides.space()
348                    } else {
349                        self.guides.vertical()
350                    }
351                };
352
353                x = draw_text_span(frame, x, y, guide, self.guide_style, max_x);
354            }
355        } else if depth > 0 {
356            // Minimal rendering: indent with spaces
357            let indent = "    ".repeat(depth);
358            x = draw_text_span(frame, x, y, &indent, Style::default(), max_x);
359        }
360
361        // Draw label
362        let style = if depth == 0 && self.show_root {
363            self.root_style
364        } else {
365            self.label_style
366        };
367
368        if deg.apply_styling() {
369            draw_text_span(frame, x, y, &node.label, style, max_x);
370        } else {
371            draw_text_span(frame, x, y, &node.label, Style::default(), max_x);
372        }
373
374        // Register hit region for the row
375        if let Some(id) = self.hit_id {
376            let row_area = Rect::new(area.x, y, area.width, 1);
377            frame.register_hit(row_area, id, HitRegion::Content, *current_row as u64);
378        }
379
380        *current_row += 1;
381
382        if !node.expanded {
383            return;
384        }
385
386        let child_count = node.children.len();
387        for (i, child) in node.children.iter().enumerate() {
388            is_last.push(i == child_count - 1);
389            self.render_node(child, depth + 1, is_last, area, frame, current_row, deg);
390            is_last.pop();
391        }
392    }
393}
394
395impl Widget for Tree {
396    fn render(&self, area: Rect, frame: &mut Frame) {
397        if area.width == 0 || area.height == 0 {
398            return;
399        }
400
401        let deg = frame.buffer.degradation;
402        let mut current_row = 0;
403        let mut is_last = Vec::with_capacity(8);
404
405        if self.show_root {
406            self.render_node(
407                &self.root,
408                0,
409                &mut is_last,
410                area,
411                frame,
412                &mut current_row,
413                deg,
414            );
415        } else if self.root.expanded {
416            // If root is hidden but expanded, render children as top-level nodes.
417            // We do NOT push to is_last for the root level, effectively shifting
418            // the hierarchy up by one level.
419            let child_count = self.root.children.len();
420            for (i, child) in self.root.children.iter().enumerate() {
421                is_last.push(i == child_count - 1);
422                self.render_node(
423                    child,
424                    0, // Children become depth 0
425                    &mut is_last,
426                    area,
427                    frame,
428                    &mut current_row,
429                    deg,
430                );
431                is_last.pop();
432            }
433        }
434    }
435
436    fn is_essential(&self) -> bool {
437        false
438    }
439}
440
441// ============================================================================
442// Stateful Persistence Implementation
443// ============================================================================
444
445/// Persistable state for a [`Tree`] widget.
446///
447/// Stores the set of expanded node paths to restore tree expansion state.
448#[derive(Clone, Debug, Default, PartialEq)]
449#[cfg_attr(
450    feature = "state-persistence",
451    derive(serde::Serialize, serde::Deserialize)
452)]
453pub struct TreePersistState {
454    /// Set of expanded node paths (e.g., "root/src/main.rs").
455    pub expanded_paths: HashSet<String>,
456}
457
458impl crate::stateful::Stateful for Tree {
459    type State = TreePersistState;
460
461    fn state_key(&self) -> crate::stateful::StateKey {
462        crate::stateful::StateKey::new("Tree", self.persistence_id.as_deref().unwrap_or("default"))
463    }
464
465    fn save_state(&self) -> TreePersistState {
466        let mut expanded_paths = HashSet::new();
467        self.root.collect_expanded("", &mut expanded_paths);
468        TreePersistState { expanded_paths }
469    }
470
471    fn restore_state(&mut self, state: TreePersistState) {
472        self.root.apply_expanded("", &state.expanded_paths);
473    }
474}
475
476// ============================================================================
477// Undo Support Implementation
478// ============================================================================
479
480impl UndoSupport for Tree {
481    fn undo_widget_id(&self) -> UndoWidgetId {
482        self.undo_id
483    }
484
485    fn create_snapshot(&self) -> Box<dyn Any + Send> {
486        Box::new(self.save_state())
487    }
488
489    fn restore_snapshot(&mut self, snapshot: &dyn Any) -> bool {
490        if let Some(snap) = snapshot.downcast_ref::<TreePersistState>() {
491            self.restore_state(snap.clone());
492            true
493        } else {
494            false
495        }
496    }
497}
498
499impl TreeUndoExt for Tree {
500    fn is_node_expanded(&self, path: &[usize]) -> bool {
501        self.get_node_at_path(path)
502            .map(|node| node.is_expanded())
503            .unwrap_or(false)
504    }
505
506    fn expand_node(&mut self, path: &[usize]) {
507        if let Some(node) = self.get_node_at_path_mut(path) {
508            node.expanded = true;
509        }
510    }
511
512    fn collapse_node(&mut self, path: &[usize]) {
513        if let Some(node) = self.get_node_at_path_mut(path) {
514            node.expanded = false;
515        }
516    }
517}
518
519impl Tree {
520    /// Get the undo widget ID for this tree.
521    #[must_use]
522    pub fn undo_id(&self) -> UndoWidgetId {
523        self.undo_id
524    }
525
526    /// Get a reference to a node at the given path (indices from root).
527    fn get_node_at_path(&self, path: &[usize]) -> Option<&TreeNode> {
528        let mut current = &self.root;
529        for &idx in path {
530            current = current.children.get(idx)?;
531        }
532        Some(current)
533    }
534
535    /// Get a mutable reference to a node at the given path (indices from root).
536    fn get_node_at_path_mut(&mut self, path: &[usize]) -> Option<&mut TreeNode> {
537        let mut current = &mut self.root;
538        for &idx in path {
539            current = current.children.get_mut(idx)?;
540        }
541        Some(current)
542    }
543}
544
545// ---------------------------------------------------------------------------
546// Test-only flatten helpers
547// ---------------------------------------------------------------------------
548
549#[cfg(test)]
550#[derive(Debug, Clone, PartialEq, Eq)]
551struct FlatNode {
552    label: String,
553    depth: usize,
554}
555
556#[cfg(test)]
557fn flatten_visible(node: &TreeNode, depth: usize, out: &mut Vec<FlatNode>) {
558    out.push(FlatNode {
559        label: node.label.clone(),
560        depth,
561    });
562    if node.expanded {
563        for child in &node.children {
564            flatten_visible(child, depth + 1, out);
565        }
566    }
567}
568
569#[cfg(test)]
570impl Tree {
571    fn flatten(&self) -> Vec<FlatNode> {
572        let mut out = Vec::new();
573        if self.show_root {
574            flatten_visible(&self.root, 0, &mut out);
575        } else if self.root.expanded {
576            for child in &self.root.children {
577                flatten_visible(child, 0, &mut out);
578            }
579        }
580        out
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use ftui_render::frame::Frame;
588    use ftui_render::grapheme_pool::GraphemePool;
589
590    fn simple_tree() -> TreeNode {
591        TreeNode::new("root")
592            .child(
593                TreeNode::new("a")
594                    .child(TreeNode::new("a1"))
595                    .child(TreeNode::new("a2")),
596            )
597            .child(TreeNode::new("b"))
598    }
599
600    #[test]
601    fn tree_node_basics() {
602        let node = TreeNode::new("hello");
603        assert_eq!(node.label(), "hello");
604        assert!(node.children().is_empty());
605        assert!(node.is_expanded());
606    }
607
608    #[test]
609    fn tree_node_children() {
610        let root = simple_tree();
611        assert_eq!(root.children().len(), 2);
612        assert_eq!(root.children()[0].label(), "a");
613        assert_eq!(root.children()[0].children().len(), 2);
614    }
615
616    #[test]
617    fn tree_node_visible_count() {
618        let root = simple_tree();
619        // root + a + a1 + a2 + b = 5
620        assert_eq!(root.visible_count(), 5);
621    }
622
623    #[test]
624    fn tree_node_collapsed() {
625        let root = TreeNode::new("root")
626            .child(
627                TreeNode::new("a")
628                    .with_expanded(false)
629                    .child(TreeNode::new("a1"))
630                    .child(TreeNode::new("a2")),
631            )
632            .child(TreeNode::new("b"));
633        // root + a (collapsed, so no a1/a2) + b = 3
634        assert_eq!(root.visible_count(), 3);
635    }
636
637    #[test]
638    fn tree_node_toggle() {
639        let mut node = TreeNode::new("x");
640        assert!(node.is_expanded());
641        node.toggle_expanded();
642        assert!(!node.is_expanded());
643        node.toggle_expanded();
644        assert!(node.is_expanded());
645    }
646
647    #[test]
648    fn tree_guides_unicode() {
649        let g = TreeGuides::Unicode;
650        assert!(g.branch().contains('├'));
651        assert!(g.last().contains('└'));
652        assert!(g.vertical().contains('│'));
653    }
654
655    #[test]
656    fn tree_guides_ascii() {
657        let g = TreeGuides::Ascii;
658        assert!(g.branch().contains('+'));
659        assert!(g.vertical().contains('|'));
660    }
661
662    #[test]
663    fn tree_guides_width() {
664        for g in [
665            TreeGuides::Ascii,
666            TreeGuides::Unicode,
667            TreeGuides::Bold,
668            TreeGuides::Double,
669            TreeGuides::Rounded,
670        ] {
671            assert_eq!(g.width(), 4);
672        }
673    }
674
675    #[test]
676    fn tree_render_basic() {
677        let tree = Tree::new(simple_tree());
678
679        let mut pool = GraphemePool::new();
680        let mut frame = Frame::new(40, 10, &mut pool);
681        let area = Rect::new(0, 0, 40, 10);
682        tree.render(area, &mut frame);
683
684        // Root label at (0, 0)
685        let cell = frame.buffer.get(0, 0).unwrap();
686        assert_eq!(cell.content.as_char(), Some('r'));
687    }
688
689    #[test]
690    fn tree_render_guides_present() {
691        let tree = Tree::new(simple_tree()).with_guides(TreeGuides::Ascii);
692
693        let mut pool = GraphemePool::new();
694        let mut frame = Frame::new(40, 10, &mut pool);
695        let area = Rect::new(0, 0, 40, 10);
696        tree.render(area, &mut frame);
697
698        // Row 1 should be child "a" with branch guide "+-- "
699        // First char of guide at (0, 1)
700        let cell = frame.buffer.get(0, 1).unwrap();
701        assert_eq!(cell.content.as_char(), Some('+'));
702    }
703
704    #[test]
705    fn tree_render_last_guide() {
706        let tree = Tree::new(
707            TreeNode::new("root")
708                .child(TreeNode::new("a"))
709                .child(TreeNode::new("b")),
710        )
711        .with_guides(TreeGuides::Ascii);
712
713        let mut pool = GraphemePool::new();
714        let mut frame = Frame::new(40, 10, &mut pool);
715        let area = Rect::new(0, 0, 40, 10);
716        tree.render(area, &mut frame);
717
718        // Row 1: "+-- a" (not last)
719        let cell = frame.buffer.get(0, 1).unwrap();
720        assert_eq!(cell.content.as_char(), Some('+'));
721
722        // Row 2: "`-- b" (last child)
723        let cell = frame.buffer.get(0, 2).unwrap();
724        assert_eq!(cell.content.as_char(), Some('`'));
725    }
726
727    #[test]
728    fn tree_render_zero_area() {
729        let tree = Tree::new(simple_tree());
730        let mut pool = GraphemePool::new();
731        let mut frame = Frame::new(40, 10, &mut pool);
732        tree.render(Rect::new(0, 0, 0, 0), &mut frame); // No panic
733    }
734
735    #[test]
736    fn tree_render_truncated_height() {
737        let tree = Tree::new(simple_tree());
738        let mut pool = GraphemePool::new();
739        let mut frame = Frame::new(40, 2, &mut pool);
740        let area = Rect::new(0, 0, 40, 2);
741        tree.render(area, &mut frame); // Only first 2 rows render, no panic
742    }
743
744    #[test]
745    fn is_not_essential() {
746        let tree = Tree::new(TreeNode::new("x"));
747        assert!(!tree.is_essential());
748    }
749
750    #[test]
751    fn tree_root_access() {
752        let mut tree = Tree::new(TreeNode::new("root"));
753        assert_eq!(tree.root().label(), "root");
754        tree.root_mut().toggle_expanded();
755        assert!(!tree.root().is_expanded());
756    }
757
758    #[test]
759    fn tree_guides_default() {
760        let g = TreeGuides::default();
761        assert_eq!(g, TreeGuides::Unicode);
762    }
763
764    #[test]
765    fn tree_guides_rounded() {
766        let g = TreeGuides::Rounded;
767        assert!(g.last().contains('╰'));
768    }
769
770    #[test]
771    fn tree_deep_nesting() {
772        let node = TreeNode::new("d3");
773        let node = TreeNode::new("d2").child(node);
774        let node = TreeNode::new("d1").child(node);
775        let root = TreeNode::new("root").child(node);
776
777        let tree = Tree::new(root);
778        let flat = tree.flatten();
779        assert_eq!(flat.len(), 4);
780        assert_eq!(flat[3].depth, 3);
781    }
782
783    #[test]
784    fn tree_node_with_children_vec() {
785        let root = TreeNode::new("root").with_children(vec![
786            TreeNode::new("a"),
787            TreeNode::new("b"),
788            TreeNode::new("c"),
789        ]);
790        assert_eq!(root.children().len(), 3);
791    }
792
793    // --- Stateful Persistence tests ---
794
795    use crate::stateful::Stateful;
796
797    #[test]
798    fn tree_with_persistence_id() {
799        let tree = Tree::new(TreeNode::new("root")).with_persistence_id("file-tree");
800        assert_eq!(tree.persistence_id(), Some("file-tree"));
801    }
802
803    #[test]
804    fn tree_default_no_persistence_id() {
805        let tree = Tree::new(TreeNode::new("root"));
806        assert_eq!(tree.persistence_id(), None);
807    }
808
809    #[test]
810    fn tree_save_restore_round_trip() {
811        // Create tree with some nodes expanded, some collapsed
812        let mut tree = Tree::new(
813            TreeNode::new("root")
814                .child(
815                    TreeNode::new("src")
816                        .child(TreeNode::new("main.rs"))
817                        .child(TreeNode::new("lib.rs")),
818                )
819                .child(TreeNode::new("tests").with_expanded(false)),
820        )
821        .with_persistence_id("test");
822
823        // Verify initial state: root and src expanded, tests collapsed
824        assert!(tree.root().is_expanded());
825        assert!(tree.root().children()[0].is_expanded()); // src
826        assert!(!tree.root().children()[1].is_expanded()); // tests
827
828        let saved = tree.save_state();
829
830        // Verify saved state captures expanded nodes
831        assert!(saved.expanded_paths.contains("root"));
832        assert!(saved.expanded_paths.contains("root/src"));
833        assert!(!saved.expanded_paths.contains("root/tests"));
834
835        // Modify tree state (collapse src)
836        tree.root_mut().children[0].toggle_expanded();
837        assert!(!tree.root().children()[0].is_expanded());
838
839        // Restore
840        tree.restore_state(saved);
841
842        // Verify restored state
843        assert!(tree.root().is_expanded());
844        assert!(tree.root().children()[0].is_expanded()); // src restored
845        assert!(!tree.root().children()[1].is_expanded()); // tests still collapsed
846    }
847
848    #[test]
849    fn tree_state_key_uses_persistence_id() {
850        let tree = Tree::new(TreeNode::new("root")).with_persistence_id("project-explorer");
851        let key = tree.state_key();
852        assert_eq!(key.widget_type, "Tree");
853        assert_eq!(key.instance_id, "project-explorer");
854    }
855
856    #[test]
857    fn tree_state_key_default_when_no_id() {
858        let tree = Tree::new(TreeNode::new("root"));
859        let key = tree.state_key();
860        assert_eq!(key.widget_type, "Tree");
861        assert_eq!(key.instance_id, "default");
862    }
863
864    #[test]
865    fn tree_persist_state_default() {
866        let persist = TreePersistState::default();
867        assert!(persist.expanded_paths.is_empty());
868    }
869
870    #[test]
871    fn tree_collect_expanded_only_includes_nodes_with_children() {
872        let tree = Tree::new(
873            TreeNode::new("root").child(TreeNode::new("leaf")), // leaf has no children
874        );
875
876        let saved = tree.save_state();
877
878        // Only root is expanded (and has children)
879        assert!(saved.expanded_paths.contains("root"));
880        // leaf has no children, so it's not tracked
881        assert!(!saved.expanded_paths.contains("root/leaf"));
882    }
883
884    // ============================================================================
885    // Undo Support Tests
886    // ============================================================================
887
888    #[test]
889    fn tree_undo_widget_id_unique() {
890        let tree1 = Tree::new(TreeNode::new("root1"));
891        let tree2 = Tree::new(TreeNode::new("root2"));
892        assert_ne!(tree1.undo_id(), tree2.undo_id());
893    }
894
895    #[test]
896    fn tree_undo_snapshot_and_restore() {
897        // Nodes must have children for their expanded state to be tracked
898        let mut tree = Tree::new(
899            TreeNode::new("root")
900                .child(
901                    TreeNode::new("a")
902                        .with_expanded(true)
903                        .child(TreeNode::new("a_child")),
904                )
905                .child(
906                    TreeNode::new("b")
907                        .with_expanded(false)
908                        .child(TreeNode::new("b_child")),
909                ),
910        );
911
912        // Create snapshot
913        let snapshot = tree.create_snapshot();
914
915        // Verify initial state
916        assert!(tree.is_node_expanded(&[0])); // a
917        assert!(!tree.is_node_expanded(&[1])); // b
918
919        // Modify state
920        tree.collapse_node(&[0]); // collapse a
921        tree.expand_node(&[1]); // expand b
922        assert!(!tree.is_node_expanded(&[0]));
923        assert!(tree.is_node_expanded(&[1]));
924
925        // Restore snapshot
926        assert!(tree.restore_snapshot(&*snapshot));
927
928        // Verify restored state
929        assert!(tree.is_node_expanded(&[0])); // a back to expanded
930        assert!(!tree.is_node_expanded(&[1])); // b back to collapsed
931    }
932
933    #[test]
934    fn tree_expand_collapse_node() {
935        let mut tree =
936            Tree::new(TreeNode::new("root").child(TreeNode::new("child").with_expanded(true)));
937
938        // Initial state
939        assert!(tree.is_node_expanded(&[0]));
940
941        // Collapse
942        tree.collapse_node(&[0]);
943        assert!(!tree.is_node_expanded(&[0]));
944
945        // Expand again
946        tree.expand_node(&[0]);
947        assert!(tree.is_node_expanded(&[0]));
948    }
949
950    #[test]
951    fn tree_node_path_navigation() {
952        let tree = Tree::new(
953            TreeNode::new("root")
954                .child(
955                    TreeNode::new("a")
956                        .child(TreeNode::new("a1"))
957                        .child(TreeNode::new("a2")),
958                )
959                .child(TreeNode::new("b")),
960        );
961
962        // Test path navigation
963        assert_eq!(tree.get_node_at_path(&[]).map(|n| n.label()), Some("root"));
964        assert_eq!(tree.get_node_at_path(&[0]).map(|n| n.label()), Some("a"));
965        assert_eq!(tree.get_node_at_path(&[1]).map(|n| n.label()), Some("b"));
966        assert_eq!(
967            tree.get_node_at_path(&[0, 0]).map(|n| n.label()),
968            Some("a1")
969        );
970        assert_eq!(
971            tree.get_node_at_path(&[0, 1]).map(|n| n.label()),
972            Some("a2")
973        );
974        assert!(tree.get_node_at_path(&[5]).is_none()); // Invalid path
975    }
976
977    #[test]
978    fn tree_restore_wrong_snapshot_type_fails() {
979        use std::any::Any;
980        let mut tree = Tree::new(TreeNode::new("root"));
981        let wrong_snapshot: Box<dyn Any + Send> = Box::new(42i32);
982        assert!(!tree.restore_snapshot(&*wrong_snapshot));
983    }
984}