use std::any::Any;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::{Block, Paragraph, Widget};
use typ_core::{KeyChord, Panel, PanelEvent, RenderContext};
#[derive(Debug, Clone)]
pub struct Entry {
pub path: PathBuf,
pub depth: usize,
pub is_dir: bool,
}
pub struct TreePanel {
root: PathBuf,
entries: Vec<Entry>,
expanded: HashSet<PathBuf>,
selected: usize,
top_line: usize,
height: usize,
}
impl TreePanel {
pub fn new(root: &Path) -> Result<Self> {
let mut panel = Self {
root: root.to_path_buf(),
entries: Vec::new(),
expanded: HashSet::new(),
selected: 0,
top_line: 0,
height: 0,
};
panel.rebuild()?;
Ok(panel)
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
pub fn selected(&self) -> Option<&Path> {
self.entries.get(self.selected).map(|e| e.path.as_path())
}
pub fn depth_of_selection(&self) -> usize {
self.entries.get(self.selected).map_or(0, |e| e.depth)
}
pub fn root(&self) -> &Path {
&self.root
}
fn rebuild(&mut self) -> Result<()> {
let previous = self.selected().map(Path::to_path_buf);
let mut entries = Vec::new();
collect(&self.root, 0, &self.expanded, &mut entries)?;
self.entries = entries;
self.selected = previous
.and_then(|p| self.entries.iter().position(|e| e.path == p))
.unwrap_or(self.selected)
.min(self.entries.len().saturating_sub(1));
Ok(())
}
fn move_selection(&mut self, delta: i32) {
let last = self.entries.len().saturating_sub(1) as i64;
self.selected = (self.selected as i64 + delta as i64).clamp(0, last) as usize;
self.scroll_to_selection();
}
fn scroll_to_selection(&mut self) {
if self.height == 0 {
return;
}
if self.selected < self.top_line {
self.top_line = self.selected;
} else if self.selected >= self.top_line + self.height {
self.top_line = self.selected - self.height + 1;
}
}
fn set_expanded(&mut self, path: PathBuf, expand: bool) -> Vec<PanelEvent> {
if expand {
self.expanded.insert(path);
} else {
self.expanded.retain(|p| !p.starts_with(&path));
}
match self.rebuild() {
Ok(()) => vec![PanelEvent::NeedsRedraw],
Err(e) => vec![PanelEvent::Notify {
level: typ_core::NotifyLevel::Error,
message: format!("{e:#}"),
}],
}
}
fn activate(&mut self) -> Vec<PanelEvent> {
let Some(entry) = self.entries.get(self.selected).cloned() else {
return Vec::new();
};
if entry.is_dir {
let expand = !self.expanded.contains(&entry.path);
self.set_expanded(entry.path, expand)
} else {
vec![PanelEvent::OpenFile {
path: entry.path,
line: 0,
col: 0,
}]
}
}
fn list_area(area: Rect) -> Rect {
Block::bordered().inner(area)
}
}
fn collect(
dir: &Path,
depth: usize,
expanded: &HashSet<PathBuf>,
out: &mut Vec<Entry>,
) -> Result<()> {
let mut children: Vec<Entry> = std::fs::read_dir(dir)
.with_context(|| format!("reading {}", dir.display()))?
.filter_map(|e| e.ok())
.map(|e| Entry {
is_dir: e.path().is_dir(),
path: e.path(),
depth,
})
.collect();
children.sort_by_key(|e| {
(
!e.is_dir,
e.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase(),
)
});
for child in children {
let descend = child.is_dir && expanded.contains(&child.path);
let path = child.path.clone();
out.push(child);
if descend {
collect(&path, depth + 1, expanded, out)?;
}
}
Ok(())
}
impl Panel for TreePanel {
fn name(&self) -> &'static str {
"tree"
}
fn title(&self) -> String {
self.root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("/")
.to_string()
}
fn render(&mut self, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
let border = if ctx.is_focused {
ctx.theme.border_focused
} else {
ctx.theme.border
};
let block = Block::bordered()
.border_style(Style::default().fg(border))
.title(self.title());
let inner = block.inner(area);
block.render(area, buf);
self.height = inner.height as usize;
let end = (self.top_line + self.height).min(self.entries.len());
let lines: Vec<Line> = (self.top_line..end)
.map(|i| {
let entry = &self.entries[i];
let name = entry
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
let marker = if !entry.is_dir {
" "
} else if self.expanded.contains(&entry.path) {
"v "
} else {
"> "
};
let label = format!("{}{marker}{name}", " ".repeat(entry.depth));
let style = if i == self.selected {
Style::default()
.fg(ctx.theme.selection_fg)
.bg(ctx.theme.selection_primary_bg)
} else if entry.is_dir {
Style::default().fg(ctx.theme.tree_directory_fg)
} else {
Style::default().fg(ctx.theme.tree_file_fg)
};
Line::styled(label, style)
})
.collect();
Paragraph::new(lines)
.style(Style::default().bg(ctx.theme.bg))
.render(inner, buf);
}
fn handle_key(&mut self, chord: KeyChord) -> Vec<PanelEvent> {
match chord.raw.code {
KeyCode::Down => self.move_selection(1),
KeyCode::Up => self.move_selection(-1),
KeyCode::Enter => return self.activate(),
KeyCode::Right => {
if let Some(e) = self.entries.get(self.selected).cloned()
&& e.is_dir
&& !self.expanded.contains(&e.path)
{
return self.set_expanded(e.path, true);
}
}
KeyCode::Left => {
if let Some(e) = self.entries.get(self.selected).cloned()
&& e.is_dir
&& self.expanded.contains(&e.path)
{
return self.set_expanded(e.path, false);
}
}
_ => return Vec::new(),
}
vec![PanelEvent::NeedsRedraw]
}
fn handle_mouse(&mut self, event: MouseEvent, panel_area: Rect) -> Vec<PanelEvent> {
if event.kind != MouseEventKind::Down(MouseButton::Left) {
return Vec::new();
}
let inner = Self::list_area(panel_area);
let row = event.row.saturating_sub(inner.y) as usize;
let idx = self.top_line + row;
if idx >= self.entries.len() {
return Vec::new();
}
if idx == self.selected {
return self.activate();
}
self.selected = idx;
vec![PanelEvent::NeedsRedraw]
}
fn handle_scroll(&mut self, delta: i32, _panel_area: Rect) -> Vec<PanelEvent> {
let max_top = self.entries.len().saturating_sub(self.height.max(1));
self.top_line = (self.top_line as i64 + delta as i64).clamp(0, max_top as i64) as usize;
vec![PanelEvent::NeedsRedraw]
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}