use std::{cell::Cell, collections::HashSet};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{Frame, layout::Rect, text::Line};
use super::{chrome, list, nav};
use crate::theme::{GlyphVariant, Skin};
#[derive(Debug, Clone)]
pub struct TreeItem {
label: String,
id: Option<usize>,
children: Vec<TreeItem>,
}
impl TreeItem {
pub fn leaf(label: impl Into<String>) -> Self {
Self {
label: label.into(),
id: None,
children: Vec::new(),
}
}
pub fn leaf_with_id(label: impl Into<String>, id: usize) -> Self {
Self {
label: label.into(),
id: Some(id),
children: Vec::new(),
}
}
pub fn node(label: impl Into<String>, children: Vec<TreeItem>) -> Self {
Self {
label: label.into(),
id: None,
children,
}
}
fn has_children(&self) -> bool {
!self.children.is_empty()
}
}
struct Flat {
index: usize,
depth: usize,
label: String,
id: Option<usize>,
has_children: bool,
expanded: bool,
}
pub struct TreeView {
roots: Vec<TreeItem>,
expanded: HashSet<usize>,
cursor: usize,
offset: Cell<usize>,
decor: Option<chrome::BoxDecor>,
}
impl TreeView {
pub fn new(roots: Vec<TreeItem>) -> Self {
Self {
roots,
expanded: HashSet::new(),
cursor: 0,
offset: Cell::new(0),
decor: None,
}
}
#[must_use]
pub fn boxed(mut self, decor: chrome::BoxDecor) -> Self {
self.decor = Some(decor);
self
}
pub fn selected_label(&self) -> Option<String> {
self.flatten()
.get(self.cursor)
.map(|node| node.label.clone())
}
pub fn selected_id(&self) -> Option<usize> {
self.flatten().get(self.cursor).and_then(|node| node.id)
}
pub fn selected_is_leaf(&self) -> bool {
self.flatten()
.get(self.cursor)
.is_some_and(|node| !node.has_children)
}
pub fn handle_key(&mut self, key: KeyEvent) {
let flat = self.flatten();
if flat.is_empty() {
return;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.cursor = nav::cycle(self.cursor, flat.len(), -1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.cursor = nav::cycle(self.cursor, flat.len(), 1);
}
KeyCode::Right | KeyCode::Char('l') => {
self.set_expanded(&flat, true);
}
KeyCode::Left | KeyCode::Char('h') => {
self.set_expanded(&flat, false);
}
KeyCode::Enter | KeyCode::Char(' ') => self.toggle(&flat),
_ => {}
}
self.clamp_cursor();
}
fn set_expanded(&mut self, flat: &[Flat], expand: bool) {
let Some(node) = flat.get(self.cursor) else {
return;
};
if !node.has_children {
return;
}
if expand {
self.expanded.insert(node.index);
} else {
self.expanded.remove(&node.index);
}
}
fn toggle(&mut self, flat: &[Flat]) {
if let Some(node) = flat.get(self.cursor)
&& node.has_children
{
if node.expanded {
self.expanded.remove(&node.index);
} else {
self.expanded.insert(node.index);
}
}
}
fn clamp_cursor(&mut self) {
let len = self.flatten().len();
if self.cursor >= len {
self.cursor = len.saturating_sub(1);
}
}
fn flatten(&self) -> Vec<Flat> {
let mut out = Vec::new();
let mut counter = 0usize;
for item in &self.roots {
self.walk(item, 0, true, &mut counter, &mut out);
}
out
}
fn walk(
&self,
item: &TreeItem,
depth: usize,
visible: bool,
counter: &mut usize,
out: &mut Vec<Flat>,
) {
let index = *counter;
*counter += 1;
let expanded = self.expanded.contains(&index);
if visible {
out.push(Flat {
index,
depth,
label: item.label.clone(),
id: item.id,
has_children: item.has_children(),
expanded,
});
}
for child in &item.children {
self.walk(child, depth + 1, visible && expanded, counter, out);
}
}
pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let ascii = matches!(skin.glyphs.variant, GlyphVariant::Ascii);
let lines: Vec<Line<'static>> = self
.flatten()
.iter()
.map(|node| {
let marker = marker(node.has_children, node.expanded, ascii);
let indent = " ".repeat(node.depth);
Line::from(format!("{indent}{marker} {}", node.label))
})
.collect();
let view = list::ListView {
rows: lines,
selected: self.cursor,
offset: &self.offset,
};
match &self.decor {
Some(decor) => {
list::render_boxed(frame, area, skin, view, decor, true);
}
None => list::render_counted(frame, area, skin, view),
}
}
}
fn marker(has_children: bool, expanded: bool, ascii: bool) -> &'static str {
match (has_children, expanded, ascii) {
(false, _, _) => " ",
(true, true, false) => "\u{25be}", (true, false, false) => "\u{25b8}", (true, true, true) => "-",
(true, false, true) => "+",
}
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyModifiers;
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn sample() -> TreeView {
TreeView::new(vec![
TreeItem::node(
"src",
vec![TreeItem::leaf("main.rs"), TreeItem::leaf("lib.rs")],
),
TreeItem::leaf("Cargo.toml"),
])
}
#[test]
fn starts_collapsed_showing_only_roots() {
let view = sample();
assert_eq!(view.flatten().len(), 2);
assert_eq!(view.selected_label().as_deref(), Some("src"));
}
#[test]
fn expanding_reveals_children() {
let mut view = sample();
view.handle_key(key(KeyCode::Right));
assert_eq!(view.flatten().len(), 4);
}
#[test]
fn collapsing_hides_children_again() {
let mut view = sample();
view.handle_key(key(KeyCode::Enter)); assert_eq!(view.flatten().len(), 4);
view.handle_key(key(KeyCode::Enter)); assert_eq!(view.flatten().len(), 2);
}
#[test]
fn navigation_stays_in_bounds() {
let mut view = sample();
view.handle_key(key(KeyCode::Up)); assert_eq!(view.selected_label().as_deref(), Some("Cargo.toml"));
}
fn sample_with_ids() -> TreeView {
TreeView::new(vec![
TreeItem::node(
"decks",
vec![
TreeItem::leaf_with_id("rust", 7),
TreeItem::leaf_with_id("rust", 9),
],
),
TreeItem::leaf_with_id("geography", 3),
])
}
#[test]
fn selected_id_survives_expanding_and_collapsing() {
let mut view = sample_with_ids();
assert_eq!(view.selected_id(), None); view.handle_key(key(KeyCode::Right)); view.handle_key(key(KeyCode::Down));
assert_eq!(view.selected_id(), Some(7));
view.handle_key(key(KeyCode::Down));
assert_eq!(view.selected_id(), Some(9));
view.handle_key(key(KeyCode::Up));
view.handle_key(key(KeyCode::Up));
view.handle_key(key(KeyCode::Left)); view.handle_key(key(KeyCode::Down));
assert_eq!(view.selected_id(), Some(3));
}
#[test]
fn selected_is_leaf_separates_folders_from_leaves() {
let mut view = sample_with_ids();
assert!(!view.selected_is_leaf()); view.handle_key(key(KeyCode::Down));
assert!(view.selected_is_leaf()); assert!(!TreeView::new(Vec::new()).selected_is_leaf());
}
}