use std::cell::Cell;
use ratatui::{
buffer::Buffer,
layout::Rect,
text::Line,
widgets::{List, ListItem, ListState, StatefulWidget},
};
#[derive(Debug, Clone)]
pub enum ChildrenState<T> {
NotLoaded,
Loaded(Vec<TreeNode<T>>),
}
#[derive(Debug, Clone)]
pub(crate) struct TreeNode<T> {
pub data: T,
pub children: ChildrenState<T>,
pub expanded: bool,
pub is_dir: bool,
}
impl<T> TreeNode<T> {
pub fn leaf(data: T) -> Self {
Self {
data,
children: ChildrenState::Loaded(vec![]),
expanded: false,
is_dir: false,
}
}
pub fn inner(data: T, children: Vec<TreeNode<T>>) -> Self {
Self {
data,
children: ChildrenState::Loaded(children),
expanded: false,
is_dir: true,
}
}
pub fn unloaded(data: T) -> Self {
Self {
data,
children: ChildrenState::NotLoaded,
expanded: false,
is_dir: true,
}
}
pub fn is_leaf(&self) -> bool {
!self.is_dir
}
pub(super) fn flatten<'a>(&'a self, depth: usize, out: &mut Vec<FlatNode<'a, T>>) {
out.push(FlatNode { node: self, depth });
if self.expanded
&& let ChildrenState::Loaded(children) = &self.children {
for child in children {
child.flatten(depth + 1, out);
}
}
}
}
#[derive(Clone, Copy)]
pub struct FlatNode<'a, T> {
pub node: &'a TreeNode<T>,
pub depth: usize,
}
#[derive(Debug, Clone, Default)]
pub struct Cursor {
path: Vec<usize>,
}
impl Cursor {
fn root(idx: usize) -> Self {
Self { path: vec![idx] }
}
pub fn depth(&self) -> usize {
self.path.len().saturating_sub(1)
}
#[allow(dead_code)]
pub fn path(&self) -> &[usize] {
&self.path
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TreeAction {
Done,
NeedsLoad,
}
#[derive(Debug)]
pub struct TreeWidget<T> {
roots: Vec<TreeNode<T>>,
cursor: Cursor,
cursor_flat_idx: usize,
scroll: Cell<usize>,
}
impl<T: Clone> TreeWidget<T> {
pub fn new(roots: Vec<TreeNode<T>>) -> Self {
let cursor = if roots.is_empty() {
Cursor::default()
} else {
Cursor::root(0)
};
Self {
roots,
cursor,
cursor_flat_idx: 0,
scroll: Cell::new(0),
}
}
#[allow(dead_code)]
pub fn roots(&self) -> &[TreeNode<T>] {
&self.roots
}
#[allow(dead_code)]
pub fn cursor(&self) -> &Cursor {
&self.cursor
}
#[allow(dead_code)]
pub fn cursor_flat_idx(&self) -> usize {
self.cursor_flat_idx
}
pub(crate) fn resolve_path(&self, path: &[usize]) -> Option<&TreeNode<T>> {
let (&first, rest) = path.split_first()?;
let mut node = self.roots.get(first)?;
for &idx in rest {
match &node.children {
ChildrenState::Loaded(ch) => node = ch.get(idx)?,
ChildrenState::NotLoaded => return None,
}
}
Some(node)
}
pub(crate) fn resolve_path_mut(&mut self, path: &[usize]) -> Option<&mut TreeNode<T>> {
let (&first, rest) = path.split_first()?;
let mut node = self.roots.get_mut(first)?;
for &idx in rest {
let ch = match &mut node.children {
ChildrenState::Loaded(ch) => ch,
ChildrenState::NotLoaded => return None,
};
node = ch.get_mut(idx)?;
}
Some(node)
}
fn current(&self) -> Option<&TreeNode<T>> {
self.resolve_path(&self.cursor.path)
}
fn current_mut(&mut self) -> Option<&mut TreeNode<T>> {
let path = self.cursor.path.clone();
self.resolve_path_mut(&path)
}
fn sibling_count(&self, path: &[usize]) -> usize {
if path.len() <= 1 {
self.roots.len()
} else {
let parent_path = &path[..path.len() - 1];
self.resolve_path(parent_path)
.and_then(|p| match &p.children {
ChildrenState::Loaded(ch) => Some(ch.len()),
ChildrenState::NotLoaded => None,
})
.unwrap_or(0)
}
}
pub fn move_up(&mut self) {
if self.cursor.path.is_empty() {
return;
}
let last = *self.cursor.path.last().unwrap();
if last == 0 {
if self.cursor.path.len() > 1 {
self.cursor.path.pop();
self.cursor_flat_idx -= 1;
}
return;
}
*self.cursor.path.last_mut().unwrap() -= 1;
while let Some(node) = self.current() {
let last_child = match (&node.children, node.expanded) {
(ChildrenState::Loaded(ch), true) if !ch.is_empty() => ch.len() - 1,
_ => break,
};
self.cursor.path.push(last_child);
}
self.sync_flat_idx();
}
pub fn move_down(&mut self) {
if self.cursor.path.is_empty() {
return;
}
{
let node = match self.current() {
Some(n) => n,
None => return,
};
let has_visible_children = matches!(
&node.children,
ChildrenState::Loaded(ch) if node.expanded && !ch.is_empty()
);
if has_visible_children {
self.cursor.path.push(0);
self.cursor_flat_idx += 1;
return;
}
}
let mut path = self.cursor.path.clone();
loop {
let sibling_count = self.sibling_count(&path);
let idx = path.last_mut().unwrap();
if *idx + 1 < sibling_count {
*idx += 1;
self.cursor.path = path;
self.cursor_flat_idx += 1;
return;
}
path.pop();
if path.is_empty() {
return;
}
}
}
pub fn move_up_n(&mut self, n: usize) {
for _ in 0..n {
self.move_up();
}
}
pub fn move_down_n(&mut self, n: usize) {
for _ in 0..n {
self.move_down();
}
}
pub fn expand_or_enter(&mut self) -> TreeAction {
let (is_leaf, expanded, not_loaded) = match self.current() {
Some(n) => (
n.is_leaf(),
n.expanded,
matches!(n.children, ChildrenState::NotLoaded),
),
None => return TreeAction::Done,
};
if is_leaf {
return TreeAction::Done;
}
if not_loaded {
return TreeAction::NeedsLoad;
}
if !expanded {
self.current_mut().unwrap().expanded = true;
} else {
self.cursor.path.push(0);
self.cursor_flat_idx += 1;
}
TreeAction::Done
}
pub fn collapse_or_leave(&mut self) {
let (is_leaf, expanded) = match self.current() {
Some(n) => (n.is_leaf(), n.expanded),
None => return,
};
if !is_leaf && expanded {
self.current_mut().unwrap().expanded = false;
} else if self.cursor.path.len() > 1 {
self.cursor.path.pop();
self.cursor_flat_idx -= 1;
}
}
pub fn toggle_selected(&mut self) -> TreeAction {
let (is_leaf, expanded, not_loaded) = match self.current() {
Some(n) => (
n.is_leaf(),
n.expanded,
matches!(n.children, ChildrenState::NotLoaded),
),
None => return TreeAction::Done,
};
if is_leaf {
return TreeAction::Done;
}
if not_loaded {
return TreeAction::NeedsLoad;
}
let path_len = self.cursor.path.len();
self.current_mut().unwrap().expanded = !expanded;
if expanded {
self.cursor.path.truncate(path_len);
self.sync_flat_idx();
}
TreeAction::Done
}
pub fn inject_children(&mut self, path: &[usize], new_children: Vec<TreeNode<T>>) {
if let Some(node) = self.resolve_path_mut(path) {
node.children = ChildrenState::Loaded(new_children);
node.expanded = true;
}
self.sync_flat_idx();
}
pub fn selected(&self) -> Option<FlatNode<'_, T>> {
self.current().map(|node| FlatNode {
node,
depth: self.cursor.depth(),
})
}
pub(crate) fn sync_flat_idx(&mut self) {
let target = match self.resolve_path(&self.cursor.path) {
Some(n) => n as *const _,
None => {
self.cursor_flat_idx = 0;
return;
}
};
let mut flat = Vec::new();
for root in &self.roots {
root.flatten(0, &mut flat);
}
self.cursor_flat_idx = flat
.iter()
.position(|f| std::ptr::eq(f.node, target))
.unwrap_or(0);
}
pub(crate) fn set_cursor_path(&mut self, path: &[usize]) {
self.cursor.path = path.to_vec();
self.sync_flat_idx();
}
pub fn flatten(&self) -> Vec<FlatNode<'_, T>> {
let mut out = Vec::new();
for root in &self.roots {
root.flatten(0, &mut out);
}
out
}
pub fn render<F>(&self, area: Rect, buf: &mut Buffer, render_node: F)
where
F: Fn(&FlatNode<'_, T>, bool) -> Line<'static>,
{
let height = area.height as usize;
let cursor = self.cursor_flat_idx;
let scroll = {
let s = self.scroll.get();
let s = if cursor < s { cursor } else { s };
let s = if height > 0 && cursor >= s + height {
cursor + 1 - height
} else {
s
};
self.scroll.set(s);
s
};
let flat = self.flatten();
let items: Vec<ListItem> = flat
.iter()
.enumerate()
.skip(scroll)
.take(height)
.map(|(i, fn_)| ListItem::new(render_node(fn_, i == cursor)))
.collect();
let mut state = ListState::default();
state.select(Some(cursor.saturating_sub(scroll)));
StatefulWidget::render(List::new(items), area, buf, &mut state);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> TreeWidget<&'static str> {
TreeWidget::new(vec![
TreeNode::leaf("0"),
TreeNode::inner(
"1",
vec![
TreeNode::leaf("1.0"),
TreeNode::inner("1.1", vec![TreeNode::leaf("1.1.0")]),
],
),
TreeNode::leaf("2"),
])
}
fn data(w: &TreeWidget<&'static str>) -> &'static str {
w.selected().unwrap().node.data
}
#[test]
fn initial_cursor_at_first_root() {
let w = sample();
assert_eq!(data(&w), "0");
assert_eq!(w.cursor_flat_idx(), 0);
}
#[test]
fn move_down_across_roots_when_collapsed() {
let mut w = sample();
w.move_down();
assert_eq!(data(&w), "1");
w.move_down();
assert_eq!(data(&w), "2");
w.move_down();
assert_eq!(data(&w), "2");
}
#[test]
fn move_up_from_first_node_is_noop() {
let mut w = sample();
w.move_up();
assert_eq!(data(&w), "0");
}
#[test]
fn expand_descends_and_collapse_returns() {
let mut w = sample();
w.move_down(); w.expand_or_enter();
assert_eq!(data(&w), "1"); w.expand_or_enter();
assert_eq!(data(&w), "1.0"); w.collapse_or_leave();
assert_eq!(data(&w), "1"); }
#[test]
fn move_down_into_expanded_subtree() {
let mut w = sample();
w.move_down(); w.expand_or_enter(); w.move_down(); assert_eq!(data(&w), "1.0");
assert_eq!(w.cursor_flat_idx(), 2);
w.move_down(); assert_eq!(data(&w), "1.1");
w.move_down(); assert_eq!(data(&w), "2");
}
#[test]
fn move_up_skips_into_deepest_expanded_child() {
let mut w = sample();
w.move_down(); w.expand_or_enter(); w.move_down(); w.move_down(); w.expand_or_enter(); w.move_down(); w.move_down(); assert_eq!(data(&w), "2");
assert_eq!(w.cursor_flat_idx(), 5);
w.move_up();
assert_eq!(data(&w), "1.1.0");
assert_eq!(w.cursor_flat_idx(), 4);
}
#[test]
fn toggle_collapses_and_resyncs_flat_idx() {
let mut w = sample();
w.move_down(); w.expand_or_enter(); w.move_down(); w.move_up(); w.toggle_selected(); assert_eq!(data(&w), "1");
assert_eq!(w.cursor_flat_idx(), 1);
w.move_down();
assert_eq!(data(&w), "2");
assert_eq!(w.cursor_flat_idx(), 2);
}
#[test]
fn cursor_path_reflects_position() {
let mut w = sample();
w.move_down(); w.expand_or_enter();
w.move_down(); assert_eq!(w.cursor().path(), &[1, 0]);
w.move_down(); assert_eq!(w.cursor().path(), &[1, 1]);
}
#[test]
fn unloaded_node_returns_needs_load() {
let mut w: TreeWidget<&'static str> = TreeWidget::new(vec![TreeNode::unloaded("dir")]);
assert_eq!(w.expand_or_enter(), TreeAction::NeedsLoad);
assert_eq!(data(&w), "dir");
}
#[test]
fn inject_children_expands_node() {
let mut w: TreeWidget<&'static str> = TreeWidget::new(vec![TreeNode::unloaded("dir")]);
assert_eq!(w.expand_or_enter(), TreeAction::NeedsLoad);
w.inject_children(&[0], vec![TreeNode::leaf("child")]);
assert_eq!(data(&w), "dir");
w.move_down();
assert_eq!(data(&w), "child");
}
}