1use 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#[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 pub fn new(id: Id, value: T) -> Self {
33 Self {
34 id,
35 value,
36 children: Vec::new(),
37 }
38 }
39
40 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#[derive(Debug, Clone, Copy, Default)]
52pub enum TreeBranchMode {
53 #[default]
55 Caret,
56 Branch,
58}
59
60#[derive(Debug, Clone)]
62pub struct TreeBranchStyle {
63 pub mode: TreeBranchMode,
65 pub indent_width: usize,
67 pub connector_style: Style,
69 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#[derive(Debug, Clone)]
86pub struct TreeViewStyle {
87 pub base: BaseStyle,
89 pub selection: SelectionStyle,
91 pub scrollbar: ScrollbarStyle,
93 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 pub fn borderless() -> Self {
114 let mut style = Self::default();
115 style.base.border = None;
116 style
117 }
118
119 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#[derive(Debug, Clone)]
136pub struct TreeViewBehavior {
137 pub show_scrollbar: bool,
139 pub wrap_navigation: bool,
141 pub enter_toggles: bool,
143 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
158pub 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
171pub type TreeSelectCallback<Id, A> = Rc<dyn Fn(&Id) -> A>;
173
174pub type TreeToggleCallback<Id, A> = Rc<dyn Fn(&Id, bool) -> A>;
176
177pub struct TreeViewProps<'a, Id, T, A>
179where
180 Id: Clone + Eq + Hash + 'static,
181{
182 pub nodes: &'a [TreeNode<Id, T>],
184 pub selected_id: Option<&'a Id>,
186 pub expanded_ids: &'a HashSet<Id>,
188 pub is_focused: bool,
190 pub style: TreeViewStyle,
192 pub behavior: TreeViewBehavior,
194 #[allow(clippy::type_complexity)]
196 pub measure_node: Option<&'a dyn Fn(&TreeNode<Id, T>) -> usize>,
197 pub column_padding: usize,
199 pub on_select: TreeSelectCallback<Id, A>,
201 pub on_toggle: TreeToggleCallback<Id, A>,
203 pub render_node: &'a dyn Fn(TreeNodeRender<'_, Id, T>) -> Line<'static>,
205}
206
207pub struct TreeViewRenderProps<'a, Id, T>
209where
210 Id: Clone + Eq + Hash + 'static,
211{
212 pub nodes: &'a [TreeNode<Id, T>],
214 pub selected_id: Option<&'a Id>,
216 pub expanded_ids: &'a HashSet<Id>,
218 pub is_focused: bool,
220 pub style: TreeViewStyle,
222 pub behavior: TreeViewBehavior,
224 #[allow(clippy::type_complexity)]
226 pub measure_node: Option<&'a dyn Fn(&TreeNode<Id, T>) -> usize>,
227 pub column_padding: usize,
229 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
244pub 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 pub fn new() -> Self {
262 Self::default()
263 }
264
265 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}