use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::{
layout::{Alignment, Constraint},
text::Line,
widgets::{Block, Scrollbar},
};
use ratatui_kit::prelude::*;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::PathBuf,
};
use tui_tree_widget::{TreeItem, TreeState};
use crate::theme::AppChromeTheme;
#[derive(Default, Props)]
pub struct FileSelectProps {
pub items: Vec<TreeItem<'static, PathBuf>>,
pub on_select: Handler<'static, PathBuf>,
pub default_value: Option<usize>,
pub top_title: Option<Line<'static>>,
pub bottom_title: Option<Line<'static>>,
pub is_editing: bool,
pub empty_message: String,
pub expand_all: bool,
}
#[component]
pub fn FileSelect(props: &mut FileSelectProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let state = hooks.use_state(TreeState::default);
let theme = hooks.use_component_theme::<AppChromeTheme>();
let is_empty = props.items.is_empty();
let fingerprint = tree_fingerprint(&props.items);
let expand_all = props.expand_all;
hooks.use_effect(
|| {
let mut state = state.write();
state.select(Vec::new());
state.close_all();
if expand_all {
for branch in collect_branches(&props.items) {
state.open(branch);
}
}
},
(fingerprint, expand_all),
);
let mut on_select = props.on_select.take();
hooks.use_event_handler(EventScope::Current, EventPriority::Normal, {
let is_editing = props.is_editing;
move |event| {
let Event::Key(key) = event else {
return EventResult::Ignored;
};
if key.kind != KeyEventKind::Press || !is_editing {
return EventResult::Ignored;
}
match key.code {
KeyCode::Char('h') | KeyCode::Left => {
state.write().key_left();
EventResult::Consumed
}
KeyCode::Char('j') | KeyCode::Down => {
state.write().key_down();
EventResult::Consumed
}
KeyCode::Char('k') | KeyCode::Up => {
state.write().key_up();
EventResult::Consumed
}
KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => {
let res: Option<PathBuf> = state.read().selected().last().cloned();
if let Some(path) = res {
state.write().toggle_selected();
if path.is_file() {
on_select(path);
}
}
EventResult::Consumed
}
_ => EventResult::Ignored,
}
}
});
let mut border = Block::bordered().border_style(theme.border);
if let Some(title) = props.top_title.clone() {
border = border.title_top(title);
}
if let Some(title) = props.bottom_title.clone() {
border = border.title_bottom(title);
}
if is_empty {
return element!(
Border(
top_title: props.top_title.clone(),
bottom_title: props.bottom_title.clone(),
border_style: theme.border,
){
Center(
height:Constraint::Length(5),
width:Constraint::Percentage(50)
){
Text(
text: props.empty_message.clone(),
alignment: Alignment::Center,
style: theme.empty,
wrap: true,
)
}
}
)
.into_any();
}
element!(TreeSelect<PathBuf>(
style: theme.text,
highlight_style: theme.selected,
state: state,
items: props.items.clone(),
scrollbar: Scrollbar::default(),
block: border,
))
.into_any()
}
fn tree_fingerprint(items: &[TreeItem<'static, PathBuf>]) -> u64 {
let mut hasher = DefaultHasher::new();
hash_items(items, &mut hasher);
hasher.finish()
}
fn hash_items(items: &[TreeItem<'static, PathBuf>], hasher: &mut DefaultHasher) {
items.len().hash(hasher);
for item in items {
item.identifier().hash(hasher);
hash_items(item.children(), hasher);
}
}
fn collect_branches(items: &[TreeItem<'static, PathBuf>]) -> Vec<Vec<PathBuf>> {
let mut branches = Vec::new();
push_branches(items, &mut Vec::new(), &mut branches);
branches
}
fn push_branches(
items: &[TreeItem<'static, PathBuf>],
prefix: &mut Vec<PathBuf>,
branches: &mut Vec<Vec<PathBuf>>,
) {
for item in items {
if item.children().is_empty() {
continue;
}
prefix.push(item.identifier().clone());
branches.push(prefix.clone());
push_branches(item.children(), prefix, branches);
prefix.pop();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn leaf(name: &str) -> TreeItem<'static, PathBuf> {
TreeItem::new_leaf(PathBuf::from(name), name.to_owned())
}
fn dir(name: &str, children: Vec<TreeItem<'static, PathBuf>>) -> TreeItem<'static, PathBuf> {
TreeItem::new(PathBuf::from(name), name.to_owned(), children).unwrap()
}
#[test]
fn fingerprint_changes_when_filtering_removes_items() {
let all = vec![leaf("A.txt"), leaf("B.txt")];
let filtered = vec![leaf("A.txt")];
assert_ne!(tree_fingerprint(&all), tree_fingerprint(&filtered));
assert_eq!(tree_fingerprint(&all), tree_fingerprint(&all.clone()));
}
#[test]
fn fingerprint_is_sensitive_to_nesting() {
let flat = vec![leaf("A.txt")];
let nested = vec![dir("sub", vec![leaf("A.txt")])];
assert_ne!(tree_fingerprint(&flat), tree_fingerprint(&nested));
}
#[test]
fn branches_are_full_paths_and_exclude_leaves() {
let items = vec![dir("a", vec![dir("b", vec![leaf("c.txt")])]), leaf("d.txt")];
assert_eq!(
collect_branches(&items),
vec![
vec![PathBuf::from("a")],
vec![PathBuf::from("a"), PathBuf::from("b")],
]
);
}
}