Skip to main content

tui_dispatch_components/
tree_view.rs

1//! Tree view component with selection and expand/collapse
2
3use std::collections::HashSet;
4use std::hash::Hash;
5use std::marker::PhantomData;
6use std::rc::Rc;
7
8use crossterm::event::KeyCode;
9use ratatui::{
10    layout::Rect,
11    style::Style,
12    text::{Line, Span},
13    widgets::{Block, List, ListItem, ListState, ScrollbarOrientation, ScrollbarState},
14    Frame,
15};
16use tui_dispatch_core::{Component, EventKind, HandlerResponse};
17
18use crate::commands;
19use crate::style::{BaseStyle, ComponentStyle, Padding, ScrollbarStyle, SelectionStyle};
20use crate::{ComponentDebugEntry, ComponentDebugState, ComponentInput, InteractiveComponent};
21
22/// Tree node data structure
23#[derive(Debug, Clone)]
24pub struct TreeNode<Id, T> {
25    pub id: Id,
26    pub value: T,
27    pub children: Vec<TreeNode<Id, T>>,
28}
29
30impl<Id, T> TreeNode<Id, T> {
31    /// Create a new tree node
32    pub fn new(id: Id, value: T) -> Self {
33        Self {
34            id,
35            value,
36            children: Vec::new(),
37        }
38    }
39
40    /// Create a node with children
41    pub fn with_children(id: Id, value: T, children: Vec<TreeNode<Id, T>>) -> Self {
42        Self {
43            id,
44            value,
45            children,
46        }
47    }
48}
49
50/// Branch connector rendering mode
51#[derive(Debug, Clone, Copy, Default)]
52pub enum TreeBranchMode {
53    /// Indent with caret indicators (▸/▾)
54    #[default]
55    Caret,
56    /// Indent with branch connectors (├─/└─) plus caret
57    Branch,
58}
59
60/// Visual style for tree branches
61#[derive(Debug, Clone)]
62pub struct TreeBranchStyle {
63    /// Connector rendering mode
64    pub mode: TreeBranchMode,
65    /// Indent width per depth level
66    pub indent_width: usize,
67    /// Style for branch connectors
68    pub connector_style: Style,
69    /// Style for caret glyphs
70    pub caret_style: Style,
71}
72
73impl Default for TreeBranchStyle {
74    fn default() -> Self {
75        Self {
76            mode: TreeBranchMode::default(),
77            indent_width: 2,
78            connector_style: Style::default(),
79            caret_style: Style::default(),
80        }
81    }
82}
83
84/// Unified styling for TreeView
85#[derive(Debug, Clone)]
86pub struct TreeViewStyle {
87    /// Shared base style
88    pub base: BaseStyle,
89    /// Selection indication styling
90    pub selection: SelectionStyle,
91    /// Scrollbar styling
92    pub scrollbar: ScrollbarStyle,
93    /// Branch rendering style
94    pub branches: TreeBranchStyle,
95}
96
97impl Default for TreeViewStyle {
98    fn default() -> Self {
99        Self {
100            base: BaseStyle {
101                fg: Some(ratatui::style::Color::Reset),
102                ..Default::default()
103            },
104            selection: SelectionStyle::default(),
105            scrollbar: ScrollbarStyle::default(),
106            branches: TreeBranchStyle::default(),
107        }
108    }
109}
110
111impl TreeViewStyle {
112    /// Create a style with no border
113    pub fn borderless() -> Self {
114        let mut style = Self::default();
115        style.base.border = None;
116        style
117    }
118
119    /// Create a minimal style (no border, no padding)
120    pub fn minimal() -> Self {
121        let mut style = Self::default();
122        style.base.border = None;
123        style.base.padding = Padding::default();
124        style
125    }
126}
127
128impl ComponentStyle for TreeViewStyle {
129    fn base(&self) -> &BaseStyle {
130        &self.base
131    }
132}
133
134/// Behavior configuration for TreeView
135#[derive(Debug, Clone)]
136pub struct TreeViewBehavior {
137    /// Show scrollbar when content exceeds viewport
138    pub show_scrollbar: bool,
139    /// Wrap navigation from last to first item (and vice versa)
140    pub wrap_navigation: bool,
141    /// Enter key toggles expand/collapse
142    pub enter_toggles: bool,
143    /// Space key toggles expand/collapse
144    pub space_toggles: bool,
145}
146
147impl Default for TreeViewBehavior {
148    fn default() -> Self {
149        Self {
150            show_scrollbar: true,
151            wrap_navigation: false,
152            enter_toggles: true,
153            space_toggles: true,
154        }
155    }
156}
157
158/// Context for custom node rendering
159pub struct TreeNodeRender<'a, Id, T> {
160    pub node: &'a TreeNode<Id, T>,
161    pub depth: usize,
162    pub has_children: bool,
163    pub is_expanded: bool,
164    pub is_selected: bool,
165    pub available_width: usize,
166    pub leading_width: usize,
167    pub row_width: usize,
168    pub tree_column_width: usize,
169}
170
171/// Callback to create an action when selection changes.
172pub type TreeSelectCallback<Id, A> = Rc<dyn Fn(&Id) -> A>;
173
174/// Callback to create an action when expansion changes.
175pub type TreeToggleCallback<Id, A> = Rc<dyn Fn(&Id, bool) -> A>;
176
177/// Props for TreeView component
178pub struct TreeViewProps<'a, Id, T, A>
179where
180    Id: Clone + Eq + Hash + 'static,
181{
182    /// Nodes to render
183    pub nodes: &'a [TreeNode<Id, T>],
184    /// Currently selected node id
185    pub selected_id: Option<&'a Id>,
186    /// Expanded node ids
187    pub expanded_ids: &'a HashSet<Id>,
188    /// Whether this component has focus
189    pub is_focused: bool,
190    /// Unified styling
191    pub style: TreeViewStyle,
192    /// Behavior configuration
193    pub behavior: TreeViewBehavior,
194    /// Optional width override for column sizing
195    #[allow(clippy::type_complexity)]
196    pub measure_node: Option<&'a dyn Fn(&TreeNode<Id, T>) -> usize>,
197    /// Padding to add to the widest tree column
198    pub column_padding: usize,
199    /// Callback to create action when selection changes
200    pub on_select: TreeSelectCallback<Id, A>,
201    /// Callback to create action when expansion changes
202    pub on_toggle: TreeToggleCallback<Id, A>,
203    /// Render a node into a Line
204    pub render_node: &'a dyn Fn(TreeNodeRender<'_, Id, T>) -> Line<'static>,
205}
206
207/// Render-only props for TreeView
208pub struct TreeViewRenderProps<'a, Id, T>
209where
210    Id: Clone + Eq + Hash + 'static,
211{
212    /// Nodes to render
213    pub nodes: &'a [TreeNode<Id, T>],
214    /// Currently selected node id
215    pub selected_id: Option<&'a Id>,
216    /// Expanded node ids
217    pub expanded_ids: &'a HashSet<Id>,
218    /// Whether this component has focus
219    pub is_focused: bool,
220    /// Unified styling
221    pub style: TreeViewStyle,
222    /// Behavior configuration
223    pub behavior: TreeViewBehavior,
224    /// Optional width override for column sizing
225    #[allow(clippy::type_complexity)]
226    pub measure_node: Option<&'a dyn Fn(&TreeNode<Id, T>) -> usize>,
227    /// Padding to add to the widest tree column
228    pub column_padding: usize,
229    /// Render a node into a Line
230    pub render_node: &'a dyn Fn(TreeNodeRender<'_, Id, T>) -> Line<'static>,
231}
232
233#[derive(Clone)]
234struct FlatNode<'a, Id, T> {
235    node: &'a TreeNode<Id, T>,
236    depth: usize,
237    parent_index: Option<usize>,
238    has_children: bool,
239    is_expanded: bool,
240    is_last: bool,
241    branch_mask: Vec<bool>,
242}
243
244/// Tree view component with selection and expand/collapse
245pub struct TreeView<Id, Node = String> {
246    scroll_offset: usize,
247    _marker: PhantomData<fn() -> (Id, Node)>,
248}
249
250impl<Id, Node> Default for TreeView<Id, Node> {
251    fn default() -> Self {
252        Self {
253            scroll_offset: 0,
254            _marker: PhantomData,
255        }
256    }
257}
258
259impl<Id, Node> TreeView<Id, Node> {
260    /// Create a new TreeView
261    pub fn new() -> Self {
262        Self::default()
263    }
264
265    /// Render the widget without requiring selection callbacks.
266    pub fn render_widget(
267        &mut self,
268        frame: &mut Frame,
269        area: Rect,
270        props: TreeViewRenderProps<'_, Id, Node>,
271    ) where
272        Id: Clone + Eq + Hash + 'static,
273    {
274        self.render_with(frame, area, props);
275    }
276
277    fn ensure_visible(&mut self, selected: usize, viewport_height: usize) {
278        if viewport_height == 0 {
279            return;
280        }
281
282        if selected < self.scroll_offset {
283            self.scroll_offset = selected;
284        } else if selected >= self.scroll_offset + viewport_height {
285            self.scroll_offset = selected.saturating_sub(viewport_height - 1);
286        }
287    }
288}
289
290impl<Id, Node> TreeView<Id, Node> {
291    fn flatten_visible<'a, T>(
292        nodes: &'a [TreeNode<Id, T>],
293        expanded: &HashSet<Id>,
294    ) -> Vec<FlatNode<'a, Id, T>>
295    where
296        Id: Clone + Eq + Hash,
297    {
298        fn walk<'a, Id, T>(
299            nodes: &'a [TreeNode<Id, T>],
300            expanded: &HashSet<Id>,
301            depth: usize,
302            parent_index: Option<usize>,
303            branch_mask: Vec<bool>,
304            out: &mut Vec<FlatNode<'a, Id, T>>,
305        ) where
306            Id: Clone + Eq + Hash,
307        {
308            for (idx, node) in nodes.iter().enumerate() {
309                let is_last = idx + 1 == nodes.len();
310                let has_children = !node.children.is_empty();
311                let is_expanded = has_children && expanded.contains(&node.id);
312                let current_index = out.len();
313
314                out.push(FlatNode {
315                    node,
316                    depth,
317                    parent_index,
318                    has_children,
319                    is_expanded,
320                    is_last,
321                    branch_mask: branch_mask.clone(),
322                });
323
324                if has_children && is_expanded {
325                    let mut next_mask = branch_mask.clone();
326                    next_mask.push(!is_last);
327                    walk(
328                        &node.children,
329                        expanded,
330                        depth + 1,
331                        Some(current_index),
332                        next_mask,
333                        out,
334                    );
335                }
336            }
337        }
338
339        let mut out = Vec::new();
340        walk(nodes, expanded, 0, None, Vec::new(), &mut out);
341        out
342    }
343
344    fn marker_prefix(marker: Option<&'static str>, is_selected: bool) -> String {
345        let Some(marker) = marker else {
346            return String::new();
347        };
348        if is_selected {
349            marker.to_string()
350        } else {
351            " ".repeat(marker.chars().count())
352        }
353    }
354
355    fn caret_prefix(
356        depth: usize,
357        indent_width: usize,
358        has_children: bool,
359        is_expanded: bool,
360    ) -> (String, String) {
361        let connector = " ".repeat(depth.saturating_mul(indent_width));
362        let caret = if has_children {
363            if is_expanded {
364                "▾ "
365            } else {
366                "▸ "
367            }
368        } else {
369            "  "
370        };
371        (connector, caret.to_string())
372    }
373
374    fn branch_prefix(
375        branch_mask: &[bool],
376        indent_width: usize,
377        is_last: bool,
378        has_children: bool,
379        is_expanded: bool,
380    ) -> (String, String) {
381        let width = indent_width.max(2);
382        let mut connector = String::new();
383        for has_branch in branch_mask {
384            if *has_branch {
385                connector.push('│');
386                connector.push_str(&" ".repeat(width.saturating_sub(1)));
387            } else {
388                connector.push_str(&" ".repeat(width));
389            }
390        }
391
392        connector.push(if is_last { '└' } else { '├' });
393        connector.push_str(&"─".repeat(width.saturating_sub(1)));
394
395        let caret = if has_children {
396            if is_expanded {
397                "▾ "
398            } else {
399                "▸ "
400            }
401        } else {
402            "  "
403        };
404
405        (connector, caret.to_string())
406    }
407
408    fn build_prefix<T>(style: &TreeViewStyle, node: &FlatNode<'_, Id, T>) -> (String, String) {
409        match style.branches.mode {
410            TreeBranchMode::Caret => Self::caret_prefix(
411                node.depth,
412                style.branches.indent_width,
413                node.has_children,
414                node.is_expanded,
415            ),
416            TreeBranchMode::Branch => Self::branch_prefix(
417                &node.branch_mask,
418                style.branches.indent_width,
419                node.is_last,
420                node.has_children,
421                node.is_expanded,
422            ),
423        }
424    }
425
426    fn available_width(width: usize, prefix_len: usize, marker_len: usize) -> usize {
427        width.saturating_sub(prefix_len).saturating_sub(marker_len)
428    }
429
430    fn render_with(
431        &mut self,
432        frame: &mut Frame,
433        area: Rect,
434        props: TreeViewRenderProps<'_, Id, Node>,
435    ) where
436        Id: Clone + Eq + Hash + 'static,
437    {
438        let style = &props.style;
439
440        if let Some(bg) = style.base.bg {
441            for y in area.y..area.y.saturating_add(area.height) {
442                for x in area.x..area.x.saturating_add(area.width) {
443                    frame.buffer_mut()[(x, y)].set_bg(bg);
444                    frame.buffer_mut()[(x, y)].set_symbol(" ");
445                }
446            }
447        }
448
449        let content_area = Rect {
450            x: area.x + style.base.padding.left,
451            y: area.y + style.base.padding.top,
452            width: area.width.saturating_sub(style.base.padding.horizontal()),
453            height: area.height.saturating_sub(style.base.padding.vertical()),
454        };
455
456        let mut inner_area = content_area;
457        if let Some(border) = &style.base.border {
458            let block = Block::default()
459                .borders(border.borders)
460                .border_style(border.style_for_focus(props.is_focused));
461            inner_area = block.inner(content_area);
462            frame.render_widget(block, content_area);
463        }
464
465        let viewport_height = inner_area.height as usize;
466        let visible = Self::flatten_visible(props.nodes, props.expanded_ids);
467        let selected_idx = props
468            .selected_id
469            .and_then(|id| visible.iter().position(|n| &n.node.id == id));
470        let selected_render_idx = selected_idx.unwrap_or(0);
471
472        if let Some(selected_idx) = selected_idx {
473            if viewport_height > 0 {
474                self.ensure_visible(selected_idx, viewport_height);
475            }
476        }
477
478        if viewport_height > 0 {
479            let max_offset = visible.len().saturating_sub(viewport_height);
480            self.scroll_offset = self.scroll_offset.min(max_offset);
481        }
482
483        let show_scrollbar = props.behavior.show_scrollbar
484            && viewport_height > 0
485            && visible.len() > viewport_height
486            && inner_area.width > 1;
487        let mut list_area = inner_area;
488        let scrollbar_area = if show_scrollbar {
489            let scrollbar_area = Rect {
490                x: inner_area.x + inner_area.width.saturating_sub(1),
491                width: 1,
492                ..inner_area
493            };
494            list_area.width = list_area.width.saturating_sub(1);
495            Some(scrollbar_area)
496        } else {
497            None
498        };
499
500        let marker_len = if style.selection.disabled {
501            0
502        } else {
503            style
504                .selection
505                .marker
506                .map(|marker| marker.chars().count())
507                .unwrap_or(0)
508        };
509
510        let row_width = list_area.width as usize;
511        let max_tree_width = visible
512            .iter()
513            .map(|node| {
514                let (connector_prefix, caret_prefix) = Self::build_prefix(style, node);
515                let prefix_len = connector_prefix.chars().count() + caret_prefix.chars().count();
516                let leading_width = prefix_len + marker_len;
517                let available_width = Self::available_width(row_width, prefix_len, marker_len);
518                let content_width = if let Some(measure_node) = props.measure_node {
519                    measure_node(node.node)
520                } else {
521                    let line = (props.render_node)(TreeNodeRender {
522                        node: node.node,
523                        depth: node.depth,
524                        has_children: node.has_children,
525                        is_expanded: node.is_expanded,
526                        is_selected: false,
527                        available_width,
528                        leading_width,
529                        row_width,
530                        tree_column_width: available_width,
531                    });
532                    line.width()
533                };
534                leading_width + content_width
535            })
536            .max()
537            .unwrap_or(0)
538            .saturating_add(props.column_padding)
539            .min(row_width.saturating_sub(1).max(1));
540
541        let items: Vec<ListItem> = visible
542            .iter()
543            .enumerate()
544            .map(|(idx, node)| {
545                let is_selected = selected_idx == Some(idx);
546                let (connector_prefix, caret_prefix) = Self::build_prefix(style, node);
547                let prefix_len = connector_prefix.chars().count() + caret_prefix.chars().count();
548                let available_width = Self::available_width(row_width, prefix_len, marker_len);
549                let leading_width = prefix_len + marker_len;
550                let tree_column_width = max_tree_width
551                    .saturating_sub(leading_width)
552                    .min(available_width);
553
554                let content_line = (props.render_node)(TreeNodeRender {
555                    node: node.node,
556                    depth: node.depth,
557                    has_children: node.has_children,
558                    is_expanded: node.is_expanded,
559                    is_selected,
560                    available_width,
561                    leading_width,
562                    row_width,
563                    tree_column_width,
564                });
565
566                let mut spans = Vec::new();
567                if !style.selection.disabled {
568                    let marker_prefix = Self::marker_prefix(style.selection.marker, is_selected);
569                    if !marker_prefix.is_empty() {
570                        spans.push(Span::raw(marker_prefix));
571                    }
572                }
573                if !connector_prefix.is_empty() {
574                    spans.push(Span::styled(
575                        connector_prefix,
576                        style.branches.connector_style,
577                    ));
578                }
579                if !caret_prefix.is_empty() {
580                    spans.push(Span::styled(caret_prefix, style.branches.caret_style));
581                }
582                spans.extend(content_line.spans.iter().cloned());
583                let display_line = Line::from(spans);
584
585                if style.selection.disabled {
586                    ListItem::new(display_line)
587                } else {
588                    let item_style = if is_selected {
589                        style.selection.style.unwrap_or_default()
590                    } else {
591                        let mut s = Style::default();
592                        if let Some(fg) = style.base.fg {
593                            s = s.fg(fg);
594                        }
595                        s
596                    };
597                    ListItem::new(display_line).style(item_style)
598                }
599            })
600            .collect();
601
602        let highlight_style = if style.selection.disabled {
603            Style::default()
604        } else {
605            style.selection.style.unwrap_or_default()
606        };
607        let list = List::new(items).highlight_style(highlight_style);
608
609        let selected = if visible.is_empty() || selected_idx.is_none() {
610            None
611        } else {
612            Some(selected_render_idx)
613        };
614        let mut state = ListState::default().with_selected(selected);
615        *state.offset_mut() = self.scroll_offset;
616
617        frame.render_stateful_widget(list, list_area, &mut state);
618
619        if let Some(scrollbar_area) = scrollbar_area {
620            let scrollbar = style.scrollbar.build(ScrollbarOrientation::VerticalRight);
621            let scrollbar_len = visible
622                .len()
623                .saturating_sub(viewport_height)
624                .saturating_add(1);
625            let mut scrollbar_state = ScrollbarState::new(scrollbar_len)
626                .position(self.scroll_offset)
627                .viewport_content_length(viewport_height.max(1));
628            frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
629        }
630    }
631}
632
633impl<Id, Node, A> Component<A> for TreeView<Id, Node>
634where
635    Id: Clone + Eq + Hash + 'static,
636{
637    type Props<'a>
638        = TreeViewProps<'a, Id, Node, A>
639    where
640        Node: 'a;
641
642    fn handle_event(
643        &mut self,
644        event: &EventKind,
645        props: Self::Props<'_>,
646    ) -> impl IntoIterator<Item = A> {
647        if !props.is_focused {
648            return None;
649        }
650
651        let visible = Self::flatten_visible(props.nodes, props.expanded_ids);
652        if visible.is_empty() {
653            return None;
654        }
655
656        let selected_idx = props
657            .selected_id
658            .and_then(|id| visible.iter().position(|n| &n.node.id == id));
659        let has_selection = selected_idx.is_some();
660        let current_idx = selected_idx.unwrap_or(0);
661        let last_idx = visible.len().saturating_sub(1);
662
663        let move_selection = |idx: usize| Some((props.on_select.as_ref())(&visible[idx].node.id));
664        let toggle_node = |idx: usize, expand: bool| {
665            Some((props.on_toggle.as_ref())(&visible[idx].node.id, expand))
666        };
667
668        match event {
669            EventKind::Key(key) => match key.code {
670                KeyCode::Char('j') | KeyCode::Down => {
671                    if !has_selection {
672                        return move_selection(0);
673                    }
674                    let next = if props.behavior.wrap_navigation && current_idx == last_idx {
675                        0
676                    } else {
677                        (current_idx + 1).min(last_idx)
678                    };
679                    if next != current_idx {
680                        move_selection(next)
681                    } else {
682                        None
683                    }
684                }
685                KeyCode::Char('k') | KeyCode::Up => {
686                    if !has_selection {
687                        return move_selection(last_idx);
688                    }
689                    let next = if props.behavior.wrap_navigation && current_idx == 0 {
690                        last_idx
691                    } else {
692                        current_idx.saturating_sub(1)
693                    };
694                    if next != current_idx {
695                        move_selection(next)
696                    } else {
697                        None
698                    }
699                }
700                KeyCode::Char('g') | KeyCode::Home => {
701                    if current_idx != 0 || !has_selection {
702                        move_selection(0)
703                    } else {
704                        None
705                    }
706                }
707                KeyCode::Char('G') | KeyCode::End => {
708                    if current_idx != last_idx || !has_selection {
709                        move_selection(last_idx)
710                    } else {
711                        None
712                    }
713                }
714                KeyCode::Left => {
715                    let current = &visible[current_idx];
716                    if current.has_children && current.is_expanded {
717                        toggle_node(current_idx, false)
718                    } else if let Some(parent_idx) = current.parent_index {
719                        move_selection(parent_idx)
720                    } else {
721                        None
722                    }
723                }
724                KeyCode::Right => {
725                    let current = &visible[current_idx];
726                    if current.has_children && !current.is_expanded {
727                        toggle_node(current_idx, true)
728                    } else if current.has_children && current.is_expanded {
729                        let child_idx = current_idx + 1;
730                        if child_idx < visible.len()
731                            && visible[child_idx].parent_index == Some(current_idx)
732                        {
733                            move_selection(child_idx)
734                        } else {
735                            None
736                        }
737                    } else {
738                        None
739                    }
740                }
741                KeyCode::Enter => {
742                    let current = &visible[current_idx];
743                    if props.behavior.enter_toggles && current.has_children {
744                        toggle_node(current_idx, !current.is_expanded)
745                    } else {
746                        move_selection(current_idx)
747                    }
748                }
749                KeyCode::Char(' ') => {
750                    let current = &visible[current_idx];
751                    if props.behavior.space_toggles && current.has_children {
752                        toggle_node(current_idx, !current.is_expanded)
753                    } else {
754                        None
755                    }
756                }
757                _ => None,
758            },
759            EventKind::Scroll { delta, .. } => {
760                if *delta == 0 {
761                    None
762                } else if *delta > 0 {
763                    if !has_selection {
764                        move_selection(last_idx)
765                    } else if current_idx > 0 {
766                        move_selection(current_idx - 1)
767                    } else {
768                        None
769                    }
770                } else if !has_selection {
771                    move_selection(0)
772                } else if current_idx < last_idx {
773                    move_selection(current_idx + 1)
774                } else {
775                    None
776                }
777            }
778            _ => None,
779        }
780    }
781
782    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
783        self.render_with(
784            frame,
785            area,
786            TreeViewRenderProps {
787                nodes: props.nodes,
788                selected_id: props.selected_id,
789                expanded_ids: props.expanded_ids,
790                is_focused: props.is_focused,
791                style: props.style,
792                behavior: props.behavior,
793                measure_node: props.measure_node,
794                column_padding: props.column_padding,
795                render_node: props.render_node,
796            },
797        );
798    }
799}
800
801impl<Id, Node> ComponentDebugState for TreeView<Id, Node> {
802    fn debug_state(&self) -> Vec<ComponentDebugEntry> {
803        vec![ComponentDebugEntry::new(
804            "scroll_offset",
805            self.scroll_offset.to_string(),
806        )]
807    }
808}
809
810impl<Id, Node, A, Ctx> InteractiveComponent<A, Ctx> for TreeView<Id, Node>
811where
812    Id: Clone + Eq + Hash + 'static,
813{
814    type Props<'a>
815        = TreeViewProps<'a, Id, Node, A>
816    where
817        Node: 'a;
818
819    fn update(
820        &mut self,
821        input: ComponentInput<'_, Ctx>,
822        props: Self::Props<'_>,
823    ) -> HandlerResponse<A> {
824        let action = match input {
825            ComponentInput::Command { name, .. } => {
826                if !props.is_focused {
827                    None
828                } else {
829                    let visible = Self::flatten_visible(props.nodes, props.expanded_ids);
830                    if visible.is_empty() {
831                        None
832                    } else {
833                        let selected_idx = props
834                            .selected_id
835                            .and_then(|id| visible.iter().position(|node| &node.node.id == id));
836                        let has_selection = selected_idx.is_some();
837                        let current_idx = selected_idx.unwrap_or(0);
838                        let last_idx = visible.len().saturating_sub(1);
839
840                        let move_selection =
841                            |idx: usize| Some((props.on_select.as_ref())(&visible[idx].node.id));
842                        let toggle_node = |idx: usize, expand: bool| {
843                            Some((props.on_toggle.as_ref())(&visible[idx].node.id, expand))
844                        };
845
846                        match name {
847                            commands::NEXT | commands::DOWN => {
848                                if !has_selection {
849                                    move_selection(0)
850                                } else {
851                                    let next = if props.behavior.wrap_navigation
852                                        && current_idx == last_idx
853                                    {
854                                        0
855                                    } else {
856                                        (current_idx + 1).min(last_idx)
857                                    };
858                                    (next != current_idx)
859                                        .then(|| (props.on_select.as_ref())(&visible[next].node.id))
860                                }
861                            }
862                            commands::PREV | commands::UP => {
863                                if !has_selection {
864                                    move_selection(last_idx)
865                                } else {
866                                    let next = if props.behavior.wrap_navigation && current_idx == 0
867                                    {
868                                        last_idx
869                                    } else {
870                                        current_idx.saturating_sub(1)
871                                    };
872                                    (next != current_idx)
873                                        .then(|| (props.on_select.as_ref())(&visible[next].node.id))
874                                }
875                            }
876                            commands::FIRST | commands::HOME => {
877                                if current_idx != 0 || !has_selection {
878                                    move_selection(0)
879                                } else {
880                                    None
881                                }
882                            }
883                            commands::LAST | commands::END => {
884                                if current_idx != last_idx || !has_selection {
885                                    move_selection(last_idx)
886                                } else {
887                                    None
888                                }
889                            }
890                            commands::LEFT => {
891                                let current = &visible[current_idx];
892                                if current.has_children && current.is_expanded {
893                                    toggle_node(current_idx, false)
894                                } else if let Some(parent_idx) = current.parent_index {
895                                    move_selection(parent_idx)
896                                } else {
897                                    None
898                                }
899                            }
900                            commands::RIGHT => {
901                                let current = &visible[current_idx];
902                                if current.has_children && !current.is_expanded {
903                                    toggle_node(current_idx, true)
904                                } else if current.has_children && current.is_expanded {
905                                    let child_idx = current_idx + 1;
906                                    if child_idx < visible.len()
907                                        && visible[child_idx].parent_index == Some(current_idx)
908                                    {
909                                        move_selection(child_idx)
910                                    } else {
911                                        None
912                                    }
913                                } else {
914                                    None
915                                }
916                            }
917                            commands::TOGGLE => {
918                                let current = &visible[current_idx];
919                                if current.has_children {
920                                    toggle_node(current_idx, !current.is_expanded)
921                                } else {
922                                    None
923                                }
924                            }
925                            commands::SELECT => move_selection(current_idx),
926                            commands::CONFIRM => {
927                                let current = &visible[current_idx];
928                                if props.behavior.enter_toggles && current.has_children {
929                                    toggle_node(current_idx, !current.is_expanded)
930                                } else {
931                                    move_selection(current_idx)
932                                }
933                            }
934                            _ => None,
935                        }
936                    }
937                }
938            }
939            ComponentInput::Key(key) => {
940                <Self as Component<A>>::handle_event(self, &EventKind::Key(key), props)
941                    .into_iter()
942                    .next()
943            }
944            ComponentInput::Scroll {
945                column,
946                row,
947                delta,
948                modifiers,
949            } => <Self as Component<A>>::handle_event(
950                self,
951                &EventKind::Scroll {
952                    column,
953                    row,
954                    delta,
955                    modifiers,
956                },
957                props,
958            )
959            .into_iter()
960            .next(),
961            _ => None,
962        };
963
964        match action {
965            Some(action) => HandlerResponse::action(action),
966            None => HandlerResponse::ignored(),
967        }
968    }
969
970    fn render(&mut self, frame: &mut Frame, area: Rect, props: Self::Props<'_>) {
971        <Self as Component<A>>::render(self, frame, area, props);
972    }
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978    use tui_dispatch_core::testing::key;
979
980    #[derive(Debug, Clone, PartialEq)]
981    enum TestAction {
982        Select(String),
983        Toggle(String, bool),
984    }
985
986    fn select_action(id: &str) -> TestAction {
987        TestAction::Select(id.to_owned())
988    }
989
990    fn toggle_action(id: &str, expanded: bool) -> TestAction {
991        TestAction::Toggle(id.to_owned(), expanded)
992    }
993
994    fn render_node(ctx: TreeNodeRender<'_, String, String>) -> Line<'static> {
995        Line::raw(ctx.node.value.clone())
996    }
997
998    fn sample_tree() -> Vec<TreeNode<String, String>> {
999        vec![TreeNode::with_children(
1000            "root".to_string(),
1001            "Root".to_string(),
1002            vec![TreeNode::new("child".to_string(), "Child".to_string())],
1003        )]
1004    }
1005
1006    fn props<'a>(
1007        nodes: &'a [TreeNode<String, String>],
1008        selected: Option<&'a String>,
1009        expanded: &'a HashSet<String>,
1010    ) -> TreeViewProps<'a, String, String, TestAction> {
1011        TreeViewProps {
1012            nodes,
1013            selected_id: selected,
1014            expanded_ids: expanded,
1015            is_focused: true,
1016            style: TreeViewStyle::borderless(),
1017            behavior: TreeViewBehavior::default(),
1018            measure_node: None,
1019            column_padding: 0,
1020            on_select: Rc::new(|id| select_action(id)),
1021            on_toggle: Rc::new(|id, expanded| toggle_action(id, expanded)),
1022            render_node: &render_node,
1023        }
1024    }
1025
1026    #[test]
1027    fn test_expand_on_right() {
1028        let mut view: TreeView<String> = TreeView::new();
1029        let nodes = sample_tree();
1030        let expanded = HashSet::new();
1031
1032        let actions: Vec<_> = view
1033            .handle_event(
1034                &EventKind::Key(key("right")),
1035                props(&nodes, None, &expanded),
1036            )
1037            .into_iter()
1038            .collect();
1039
1040        assert_eq!(actions, vec![TestAction::Toggle("root".into(), true)]);
1041    }
1042
1043    #[test]
1044    fn test_collapse_on_left() {
1045        let mut view: TreeView<String> = TreeView::new();
1046        let nodes = sample_tree();
1047        let mut expanded = HashSet::new();
1048        expanded.insert("root".to_string());
1049        let selected = Some(&nodes[0].id);
1050
1051        let actions: Vec<_> = view
1052            .handle_event(
1053                &EventKind::Key(key("left")),
1054                props(&nodes, selected, &expanded),
1055            )
1056            .into_iter()
1057            .collect();
1058
1059        assert_eq!(actions, vec![TestAction::Toggle("root".into(), false)]);
1060    }
1061
1062    #[test]
1063    fn test_select_child_with_down() {
1064        let mut view: TreeView<String> = TreeView::new();
1065        let nodes = sample_tree();
1066        let mut expanded = HashSet::new();
1067        expanded.insert("root".to_string());
1068        let selected = Some(&nodes[0].id);
1069
1070        let actions: Vec<_> = view
1071            .handle_event(
1072                &EventKind::Key(key("down")),
1073                props(&nodes, selected, &expanded),
1074            )
1075            .into_iter()
1076            .collect();
1077
1078        assert_eq!(actions, vec![TestAction::Select("child".into())]);
1079    }
1080
1081    #[test]
1082    fn test_select_parent_with_left() {
1083        let mut view: TreeView<String> = TreeView::new();
1084        let nodes = sample_tree();
1085        let mut expanded = HashSet::new();
1086        expanded.insert("root".to_string());
1087        let selected = Some(&nodes[0].children[0].id);
1088
1089        let actions: Vec<_> = view
1090            .handle_event(
1091                &EventKind::Key(key("left")),
1092                props(&nodes, selected, &expanded),
1093            )
1094            .into_iter()
1095            .collect();
1096
1097        assert_eq!(actions, vec![TestAction::Select("root".into())]);
1098    }
1099}