use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, StatefulWidget, Widget};
use gravityfile_core::{FileNode, GitStatus, NodeKind};
use unicode_width::UnicodeWidthStr;
use crate::app::state::{ClipboardMode, ClipboardState};
use crate::theme::Theme;
use crate::ui::{CompactSizeBar, fit_to_width, format_size};
#[derive(Debug, Default, Clone)]
pub struct TreeState {
pub selected: usize,
pub offset: usize,
pub expanded: HashSet<PathBuf>,
}
impl TreeState {
pub fn new(root_path: PathBuf) -> Self {
Self {
selected: 0,
offset: 0,
expanded: HashSet::from([root_path]),
}
}
pub fn toggle_expand(&mut self, path: &Path) {
if self.expanded.contains(path) {
self.expanded.remove(path);
} else {
self.expanded.insert(path.to_path_buf());
}
}
pub fn expand(&mut self, path: &Path) {
self.expanded.insert(path.to_path_buf());
}
pub fn collapse(&mut self, path: &Path) {
self.expanded.remove(path);
}
pub fn is_expanded(&self, path: &PathBuf) -> bool {
self.expanded.contains(path)
}
pub fn move_up(&mut self, count: usize) {
self.selected = self.selected.saturating_sub(count);
}
pub fn move_down(&mut self, count: usize, max: usize) {
self.selected = (self.selected + count).min(max.saturating_sub(1));
}
pub fn jump_to_top(&mut self) {
self.selected = 0;
}
pub fn jump_to_bottom(&mut self, max: usize) {
self.selected = max.saturating_sub(1);
}
pub fn ensure_visible(&mut self, viewport_height: usize) {
if self.selected < self.offset {
self.offset = self.selected;
} else if self.selected >= self.offset + viewport_height {
self.offset = self.selected - viewport_height + 1;
}
}
}
#[derive(Debug, Clone)]
pub struct VisibleItem {
pub path: PathBuf,
pub node: VisibleNode,
pub depth: usize,
pub is_last_sibling: bool,
pub parent_last_siblings: Vec<bool>,
}
#[derive(Debug, Clone)]
pub struct VisibleNode {
pub name: String,
pub size: u64,
pub kind: VisibleNodeKind,
pub git_status: Option<GitStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisibleNodeKind {
Directory { expanded: bool },
File { executable: bool },
Symlink { broken: bool },
Other,
}
pub struct TreeView<'a> {
root: &'a FileNode,
root_path: &'a PathBuf,
total_size: u64,
theme: &'a Theme,
marked: &'a HashSet<PathBuf>,
clipboard: &'a ClipboardState,
block: Option<Block<'a>>,
}
impl<'a> TreeView<'a> {
pub fn new(
root: &'a FileNode,
root_path: &'a PathBuf,
theme: &'a Theme,
marked: &'a HashSet<PathBuf>,
clipboard: &'a ClipboardState,
) -> Self {
Self {
root,
root_path,
total_size: root.size,
theme,
marked,
clipboard,
block: None,
}
}
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn flatten(&self, state: &TreeState) -> Vec<VisibleItem> {
self.rows(state, 0..usize::MAX)
}
pub fn row(&self, state: &TreeState, index: usize) -> Option<VisibleItem> {
self.rows(state, index..index.saturating_add(1)).pop()
}
pub fn rows(&self, state: &TreeState, range: std::ops::Range<usize>) -> Vec<VisibleItem> {
let mut items = Vec::new();
self.walk(state, range, |_, item| items.push(item()));
items
}
pub fn flatten_count(&self, state: &TreeState) -> usize {
self.walk(state, usize::MAX..usize::MAX, |_, _| {})
}
pub fn position(&self, state: &TreeState, target: &Path) -> Option<usize> {
if !target.starts_with(self.root_path) {
return None;
}
let (mut index, mut found) = (0, None);
self.walk(state, 0..usize::MAX, |path, _| {
if found.is_none() && path == target {
found = Some(index);
}
index += 1;
});
found
}
fn walk(
&self,
state: &TreeState,
range: std::ops::Range<usize>,
mut visit: impl FnMut(&Path, &dyn Fn() -> VisibleItem),
) -> usize {
let stop = range.end;
let build =
|node: &FileNode, path: &Path, depth, is_last, lasts: &[bool], expanded| VisibleItem {
path: path.to_path_buf(),
node: VisibleNode {
name: node.display_name().into_owned(),
size: node.size,
kind: match &node.kind {
NodeKind::Directory { .. } => VisibleNodeKind::Directory { expanded },
NodeKind::File { executable } => VisibleNodeKind::File {
executable: *executable,
},
NodeKind::Symlink { broken, .. } => {
VisibleNodeKind::Symlink { broken: *broken }
}
NodeKind::Other => VisibleNodeKind::Other,
},
git_status: node.git_status,
},
depth,
is_last_sibling: is_last,
parent_last_siblings: lasts.to_vec(),
};
if stop == 0 {
return 0;
}
let mut path = self.root_path.clone();
let root_expanded = state.is_expanded(&path);
if range.contains(&0) {
visit(&path, &|| {
build(self.root, &path, 0, true, &[], root_expanded)
});
}
let mut index = 1;
if !(root_expanded && self.root.is_dir()) {
return index;
}
let mut stack = vec![(
self.root.children.iter().enumerate(),
self.root.children.len(),
)];
let mut lasts = vec![true];
while let Some((children, count)) = stack.last_mut() {
if index >= stop {
break;
}
let count = *count;
let Some((position, child)) = children.next() else {
stack.pop();
lasts.pop();
path.pop();
continue;
};
if index < range.start && !child.is_dir() {
index += 1;
continue;
}
let is_last = position + 1 == count;
let depth = stack.len();
path.push(child.file_name());
let expanded = child.is_dir() && state.is_expanded(&path);
if index >= range.start {
visit(&path, &|| {
build(child, &path, depth, is_last, &lasts, expanded)
});
}
index += 1;
if expanded && !child.children.is_empty() {
lasts.push(is_last);
stack.push((child.children.iter().enumerate(), child.children.len()));
} else {
path.pop();
}
}
index
}
}
impl StatefulWidget for TreeView<'_> {
type State = TreeState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let inner_area = if let Some(block) = &self.block {
let inner = block.inner(area);
block.clone().render(area, buf);
inner
} else {
area
};
if inner_area.height == 0 || inner_area.width == 0 {
return;
}
let viewport_height = inner_area.height as usize;
state.ensure_visible(viewport_height);
let start = state.offset;
let items = self.rows(state, start..start + viewport_height);
let end = start + items.len();
let size_bar_width: u16 = if inner_area.width >= 10 { 10 } else { 0 };
let size_text_width: u16 = 10;
for (row_idx, item_idx) in (start..end).enumerate() {
let item = &items[row_idx];
let y = inner_area.y + row_idx as u16;
let is_selected = item_idx == state.selected;
let is_marked = self.marked.contains(&item.path);
let in_clipboard = self.clipboard.paths.contains(&item.path);
let clipboard_mode = if in_clipboard {
self.clipboard.mode
} else {
ClipboardMode::Empty
};
let mut prefix = String::new();
for &parent_is_last in &item.parent_last_siblings {
prefix.push_str(if parent_is_last { " " } else { "│ " });
}
if item.depth > 0 {
prefix.push_str(if item.is_last_sibling {
"└─"
} else {
"├─"
});
}
let (checkbox, checkbox_style) = if is_marked {
("● ", Style::default().fg(self.theme.info))
} else if clipboard_mode == ClipboardMode::Cut {
("✂ ", Style::default().fg(self.theme.warning))
} else if clipboard_mode == ClipboardMode::Copy {
("⎘ ", Style::default().fg(self.theme.success))
} else {
(" ", Style::default().fg(self.theme.muted))
};
let checkbox_width: u16 = 2;
let expand_indicator = match item.node.kind {
VisibleNodeKind::Directory { expanded } => {
if expanded {
"▼ "
} else {
"▶ "
}
}
_ => " ",
};
let base_style = match item.node.kind {
VisibleNodeKind::Directory { .. } => self.theme.directory,
VisibleNodeKind::File { executable: true } => self.theme.executable,
VisibleNodeKind::File { executable: false } => self.theme.file,
VisibleNodeKind::Symlink { broken: true } => {
self.theme.symlink.add_modifier(Modifier::DIM)
}
VisibleNodeKind::Symlink { broken: false } => self.theme.symlink,
VisibleNodeKind::Other => self.theme.muted.into(),
};
let base_style = if clipboard_mode == ClipboardMode::Cut {
base_style.add_modifier(Modifier::DIM)
} else if clipboard_mode == ClipboardMode::Copy {
base_style.add_modifier(Modifier::ITALIC)
} else {
base_style
};
let prefix_width = prefix.width() + checkbox_width as usize + expand_indicator.width();
let available_for_name = inner_area
.width
.saturating_sub(prefix_width as u16)
.saturating_sub(size_bar_width + 1)
.saturating_sub(size_text_width + 1);
let name = fit_to_width(&item.node.name, available_for_name as usize);
let prefix_span = Span::styled(&prefix, self.theme.tree_lines);
let checkbox_span = Span::styled(checkbox, checkbox_style);
let expand_span = Span::styled(expand_indicator, Style::default().fg(self.theme.muted));
let name_span = Span::styled(&name, base_style);
let (git_indicator, git_style) = match item.node.git_status {
Some(status) if status.is_displayable() => {
let color = self.theme.git_status_color(status);
(
format!(" {}", status.indicator()),
Style::default().fg(color),
)
}
_ => (String::new(), Style::default()),
};
let git_span = Span::styled(&git_indicator, git_style);
let git_display_width = git_indicator.width() as u16;
let name_display_width = name.width() as u16;
let name_padding = " ".repeat(
available_for_name
.saturating_sub(name_display_width)
.saturating_sub(git_display_width) as usize,
);
let padding_span = Span::raw(&name_padding);
let size_text = if matches!(item.node.kind, VisibleNodeKind::Directory { .. })
&& item.node.size == 0
{
format!("{:>10}", "...")
} else {
format!("{:>10}", format_size(item.node.size))
};
let size_span = Span::styled(&size_text, Style::default().fg(self.theme.muted));
let line = Line::from(vec![
prefix_span,
checkbox_span,
expand_span,
name_span,
git_span,
padding_span,
Span::raw(" "),
size_span,
]);
let line = if is_selected {
line.style(self.theme.selected)
} else if is_marked {
line.style(Style::default().add_modifier(Modifier::BOLD))
} else {
line
};
let line_area = Rect::new(
inner_area.x,
y,
inner_area
.width
.saturating_sub(size_bar_width)
.saturating_sub(u16::from(size_bar_width > 0)),
1,
);
Widget::render(line, line_area, buf);
if size_bar_width > 0 {
let ratio = if self.total_size > 0 {
item.node.size as f64 / self.total_size as f64
} else {
0.0
};
let bar_area = Rect::new(
inner_area.x + inner_area.width - size_bar_width,
y,
size_bar_width,
1,
);
let bar = CompactSizeBar::new(ratio).style(self.theme.size_bar_style(ratio));
Widget::render(bar, bar_area, buf);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{sample_node_tree, sample_root_path};
use ratatui::buffer::Buffer;
fn render_at(area: Rect, state: &mut TreeState) -> Buffer {
let root = sample_node_tree();
let root_path = sample_root_path();
let theme = Theme::dark();
let marked = HashSet::new();
let clipboard = ClipboardState::default();
let mut buf = Buffer::empty(area);
let view = TreeView::new(&root, &root_path, &theme, &marked, &clipboard);
StatefulWidget::render(view, area, &mut buf, state);
buf
}
fn expanded_state() -> TreeState {
let mut state = TreeState::new(sample_root_path());
state.expand(&sample_root_path().join("src"));
state
}
#[test]
fn flatten_lists_only_expanded_subtrees() {
let root = sample_node_tree();
let root_path = sample_root_path();
let theme = Theme::dark();
let marked = HashSet::new();
let clipboard = ClipboardState::default();
let view = TreeView::new(&root, &root_path, &theme, &marked, &clipboard);
let collapsed = TreeState::new(root_path.clone());
let names: Vec<_> = view
.flatten(&collapsed)
.into_iter()
.map(|item| item.node.name)
.collect();
assert_eq!(names.len(), 8, "root plus its direct children: {names:?}");
assert!(!names.contains(&"modified.rs".to_string()));
let mut state = collapsed.clone();
state.expand(&root_path.join("src"));
let names: Vec<_> = view
.flatten(&state)
.into_iter()
.map(|item| item.node.name)
.collect();
assert!(names.contains(&"modified.rs".to_string()));
assert!(!names.contains(&"deep.txt".to_string()));
state.expand(&root_path.join("src").join("nested"));
let names: Vec<_> = view
.flatten(&state)
.into_iter()
.map(|item| item.node.name)
.collect();
assert!(names.contains(&"deep.txt".to_string()));
}
#[test]
fn flatten_count_matches_flatten_length() {
let root = sample_node_tree();
let root_path = sample_root_path();
let theme = Theme::dark();
let marked = HashSet::new();
let clipboard = ClipboardState::default();
for state in [TreeState::new(root_path.clone()), expanded_state()] {
let view = TreeView::new(&root, &root_path, &theme, &marked, &clipboard);
let counted = view.flatten_count(&state);
let view = TreeView::new(&root, &root_path, &theme, &marked, &clipboard);
assert_eq!(counted, view.flatten(&state).len());
}
}
#[test]
fn every_window_matches_the_same_slice_of_flatten() {
let root = sample_node_tree();
let root_path = sample_root_path();
let theme = Theme::dark();
let marked = HashSet::new();
let clipboard = ClipboardState::default();
let mut state = expanded_state();
state.expand(&root_path.join("src").join("nested"));
let view = TreeView::new(&root, &root_path, &theme, &marked, &clipboard);
let items = view.flatten(&state);
for (index, item) in items.iter().enumerate() {
assert_eq!(view.position(&state, &item.path), Some(index));
}
assert_eq!(view.position(&state, &root_path.join("missing")), None);
let all: Vec<String> = items.iter().map(|item| format!("{item:?}")).collect();
for start in 0..=all.len() {
for end in start..=all.len() + 1 {
let window: Vec<String> = view
.rows(&state, start..end)
.iter()
.map(|item| format!("{item:?}"))
.collect();
assert_eq!(window, all[start..end.min(all.len())], "{start}..{end}");
}
}
}
#[test]
fn flatten_builds_paths_by_joining_names() {
let root = sample_node_tree();
let root_path = sample_root_path();
let theme = Theme::dark();
let marked = HashSet::new();
let clipboard = ClipboardState::default();
let mut state = expanded_state();
state.expand(&root_path.join("src").join("nested"));
let view = TreeView::new(&root, &root_path, &theme, &marked, &clipboard);
let items = view.flatten(&state);
let deep = items
.iter()
.find(|item| item.node.name == "deep.txt")
.expect("deep.txt is visible");
assert_eq!(deep.path, root_path.join("src/nested/deep.txt"));
assert_eq!(deep.depth, 3);
}
#[test]
fn toggle_expand_flips_state() {
let mut state = TreeState::new(sample_root_path());
let path = sample_root_path().join("src");
assert!(!state.is_expanded(&path));
state.toggle_expand(&path);
assert!(state.is_expanded(&path));
state.toggle_expand(&path);
assert!(!state.is_expanded(&path));
}
#[test]
fn ensure_visible_scrolls_the_minimum_distance() {
let mut state = TreeState::new(sample_root_path());
state.selected = 12;
state.ensure_visible(10);
assert_eq!(state.offset, 3);
state.selected = 1;
state.ensure_visible(10);
assert_eq!(state.offset, 1);
state.selected = 5;
state.ensure_visible(10);
assert_eq!(state.offset, 1);
}
#[test]
fn navigation_stays_within_bounds() {
let mut state = TreeState::new(sample_root_path());
state.move_up(100);
assert_eq!(state.selected, 0);
state.move_down(100, 8);
assert_eq!(state.selected, 7);
state.jump_to_top();
assert_eq!(state.selected, 0);
state.jump_to_bottom(8);
assert_eq!(state.selected, 7);
state.jump_to_bottom(0);
assert_eq!(state.selected, 0);
state.move_down(1, 0);
assert_eq!(state.selected, 0);
}
#[test]
fn renders_at_every_size_without_panicking() {
for width in 1u16..=60 {
for height in [1u16, 2, 3, 10, 40] {
let mut state = expanded_state();
let _ = render_at(Rect::new(0, 0, width, height), &mut state);
}
}
}
#[test]
fn renders_at_every_offset_within_a_block() {
for x in [0u16, 1, 7] {
for y in [0u16, 1, 5] {
let area = Rect::new(x, y, 40, 12);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
let root = sample_node_tree();
let root_path = sample_root_path();
let theme = Theme::dark();
let marked = HashSet::new();
let clipboard = ClipboardState::default();
let mut state = expanded_state();
let view = TreeView::new(&root, &root_path, &theme, &marked, &clipboard)
.block(Block::default().borders(ratatui::widgets::Borders::ALL));
StatefulWidget::render(view, area, &mut buf, &mut state);
}
}
}
#[test]
fn a_narrow_column_still_renders_rows() {
let mut state = expanded_state();
let buf = render_at(Rect::new(0, 0, 9, 10), &mut state);
let rendered: String = buf.content().iter().map(|cell| cell.symbol()).collect();
assert!(
rendered.contains('├') || rendered.contains('└'),
"narrow render produced no rows at all: {rendered:?}"
);
}
#[test]
fn a_wide_column_renders_the_size_bar() {
let mut state = expanded_state();
let buf = render_at(Rect::new(0, 0, 60, 10), &mut state);
let rendered: String = buf.content().iter().map(|cell| cell.symbol()).collect();
assert!(
rendered.contains("65.5 kB"),
"expected a formatted size in the row: {rendered:?}"
);
}
#[test]
fn exactly_one_row_carries_the_selection_highlight() {
let mut state = expanded_state();
state.selected = 2;
let buf = render_at(Rect::new(0, 0, 60, 10), &mut state);
let selected_bg = Theme::dark()
.selected
.bg
.expect("selection has a background");
let highlighted: Vec<u16> = (0..10u16)
.filter(|&y| buf[(0, y)].bg == selected_bg)
.collect();
assert_eq!(highlighted, vec![2], "only the selected row is highlighted");
}
}