use std::collections::HashSet;
use std::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, NodeKind};
use crate::theme::Theme;
use crate::ui::{format_size, SizeBar};
#[derive(Debug, Default, Clone)]
pub struct TreeState {
pub selected: usize,
pub offset: usize,
pub expanded: HashSet<PathBuf>,
#[allow(dead_code)]
root_path: PathBuf,
}
impl TreeState {
pub fn new(root_path: PathBuf) -> Self {
let mut expanded = HashSet::new();
expanded.insert(root_path.clone());
Self {
selected: 0,
offset: 0,
expanded,
root_path,
}
}
pub fn toggle_expand(&mut self, path: &PathBuf) {
if self.expanded.contains(path) {
self.expanded.remove(path);
} else {
self.expanded.insert(path.clone());
}
}
pub fn expand(&mut self, path: &PathBuf) {
self.expanded.insert(path.clone());
}
pub fn collapse(&mut self, path: &PathBuf) {
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,
}
#[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,
block: Option<Block<'a>>,
}
impl<'a> TreeView<'a> {
pub fn new(root: &'a FileNode, root_path: &'a PathBuf, theme: &'a Theme) -> Self {
Self {
root,
root_path,
total_size: root.size,
theme,
block: None,
}
}
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn flatten(&self, state: &TreeState) -> Vec<VisibleItem> {
let mut items = Vec::new();
self.flatten_node(
self.root,
self.root_path.clone(),
0,
true,
Vec::new(),
state,
&mut items,
);
items
}
fn flatten_node(
&self,
node: &FileNode,
path: PathBuf,
depth: usize,
is_last: bool,
parent_last_siblings: Vec<bool>,
state: &TreeState,
items: &mut Vec<VisibleItem>,
) {
let is_expanded = state.is_expanded(&path);
let kind = match &node.kind {
NodeKind::Directory { .. } => VisibleNodeKind::Directory {
expanded: is_expanded,
},
NodeKind::File { executable } => VisibleNodeKind::File {
executable: *executable,
},
NodeKind::Symlink { broken, .. } => VisibleNodeKind::Symlink { broken: *broken },
NodeKind::Other => VisibleNodeKind::Other,
};
items.push(VisibleItem {
path: path.clone(),
node: VisibleNode {
name: node.name.to_string(),
size: node.size,
kind,
},
depth,
is_last_sibling: is_last,
parent_last_siblings: parent_last_siblings.clone(),
});
if is_expanded && node.is_dir() {
let child_count = node.children.len();
for (i, child) in node.children.iter().enumerate() {
let child_path = path.join(&*child.name);
let child_is_last = i == child_count - 1;
let mut child_parent_lasts = parent_last_siblings.clone();
child_parent_lasts.push(is_last);
self.flatten_node(
child,
child_path,
depth + 1,
child_is_last,
child_parent_lasts,
state,
items,
);
}
}
}
}
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 items = self.flatten(state);
let viewport_height = inner_area.height as usize;
state.ensure_visible(viewport_height);
let start = state.offset;
let end = (start + viewport_height).min(items.len());
let size_bar_width: u16 = 10;
let size_text_width: u16 = 10;
for (row_idx, item_idx) in (start..end).enumerate() {
let item = &items[item_idx];
let y = inner_area.y + row_idx as u16;
let is_selected = item_idx == state.selected;
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 expand_indicator = match item.node.kind {
VisibleNodeKind::Directory { expanded } => {
if expanded {
"▼ "
} else {
"▶ "
}
}
_ => " ",
};
let node_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 prefix_width = prefix.len() + expand_indicator.len();
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 = if item.node.name.len() > available_for_name as usize {
let truncated_len = available_for_name.saturating_sub(1) as usize;
format!("{}…", &item.node.name[..truncated_len])
} else {
item.node.name.clone()
};
let prefix_span = Span::styled(&prefix, self.theme.tree_lines);
let expand_span = Span::styled(expand_indicator, Style::default().fg(self.theme.muted));
let name_span = Span::styled(&name, node_style);
let name_padding =
" ".repeat(available_for_name.saturating_sub(name.len() as u16) as usize);
let padding_span = Span::raw(&name_padding);
let size_text = 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,
expand_span,
name_span,
padding_span,
Span::raw(" "),
size_span,
]);
let line = if is_selected {
line.style(self.theme.selected)
} else {
line
};
let line_area = Rect::new(
inner_area.x,
y,
inner_area.width.saturating_sub(size_bar_width + 1),
1,
);
Widget::render(line, line_area, buf);
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 = SizeBar::new(ratio)
.filled_style(self.theme.size_bar_style(ratio))
.empty_style(Style::default().fg(self.theme.muted));
if is_selected {
Widget::render(bar, bar_area, buf);
} else {
Widget::render(bar, bar_area, buf);
}
}
}
}
#[allow(dead_code)]
pub fn get_selected_item<'a>(state: &TreeState, items: &'a [VisibleItem]) -> Option<&'a VisibleItem> {
items.get(state.selected)
}