Add vim bindings and directory expand/collapse

- Replace arrow keys with j/k for navigation
- Add h/l for collapse/expand directories
- Remove emoji icons, use clean text markers
- Show directories with [-]/[+] expand markers
- Track expanded state per directory path
- Add directory suffix (/) for clarity
- Update help text with vim bindings
This commit is contained in:
Christoffer Martinsson 2025-12-06 12:39:11 +01:00
parent 7ce264fd96
commit 8104f54887
3 changed files with 86 additions and 13 deletions

View File

@ -101,12 +101,18 @@ async fn handle_key_event(state: &mut AppState, key_code: KeyCode) -> Result<()>
KeyCode::Char('q') => {
state.should_quit = true;
}
KeyCode::Up => {
KeyCode::Char('k') => {
state.move_selection_up();
}
KeyCode::Down => {
KeyCode::Char('j') => {
state.move_selection_down();
}
KeyCode::Char('h') => {
state.collapse_selected();
}
KeyCode::Char('l') => {
state.expand_selected();
}
KeyCode::Enter => {
if let Some(item) = state.get_selected_item() {
if !item.node.is_dir {

View File

@ -1,5 +1,6 @@
use crate::cache::{Cache, FileTreeNode};
use crate::config::Config;
use std::collections::HashSet;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -21,6 +22,7 @@ pub struct AppState {
pub volume: i64,
pub should_quit: bool,
pub flattened_items: Vec<FlattenedItem>,
pub expanded_dirs: HashSet<PathBuf>,
}
#[derive(Debug, Clone)]
@ -32,7 +34,11 @@ pub struct FlattenedItem {
impl AppState {
pub fn new(cache: Cache, config: Config) -> Self {
let flattened_items = flatten_tree(&cache.file_tree, 0);
let mut expanded_dirs = HashSet::new();
// Start with all directories expanded
collect_all_dirs(&cache.file_tree, &mut expanded_dirs);
let flattened_items = flatten_tree(&cache.file_tree, 0, &expanded_dirs);
Self {
cache,
@ -46,6 +52,7 @@ impl AppState {
volume: 100,
should_quit: false,
flattened_items,
expanded_dirs,
}
}
@ -65,28 +72,79 @@ impl AppState {
self.flattened_items.get(self.selected_index)
}
pub fn toggle_expand(&mut self) {
if let Some(item) = self.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
if self.expanded_dirs.contains(&path) {
self.expanded_dirs.remove(&path);
} else {
self.expanded_dirs.insert(path);
}
self.rebuild_flattened_items();
}
}
}
pub fn collapse_selected(&mut self) {
if let Some(item) = self.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
self.expanded_dirs.remove(&path);
self.rebuild_flattened_items();
}
}
}
pub fn expand_selected(&mut self) {
if let Some(item) = self.get_selected_item() {
if item.node.is_dir {
let path = item.node.path.clone();
self.expanded_dirs.insert(path);
self.rebuild_flattened_items();
}
}
}
pub fn refresh_flattened_items(&mut self) {
self.flattened_items = flatten_tree(&self.cache.file_tree, 0);
self.expanded_dirs.clear();
collect_all_dirs(&self.cache.file_tree, &mut self.expanded_dirs);
self.rebuild_flattened_items();
}
fn rebuild_flattened_items(&mut self) {
self.flattened_items = flatten_tree(&self.cache.file_tree, 0, &self.expanded_dirs);
if self.selected_index >= self.flattened_items.len() {
self.selected_index = self.flattened_items.len().saturating_sub(1);
}
}
}
fn flatten_tree(nodes: &[FileTreeNode], depth: usize) -> Vec<FlattenedItem> {
fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<PathBuf>) -> Vec<FlattenedItem> {
let mut result = Vec::new();
for node in nodes {
let is_expanded = expanded_dirs.contains(&node.path);
result.push(FlattenedItem {
node: node.clone(),
depth,
is_expanded: true, // For now, all directories are expanded
is_expanded,
});
if node.is_dir && !node.children.is_empty() {
result.extend(flatten_tree(&node.children, depth + 1));
if node.is_dir && !node.children.is_empty() && is_expanded {
result.extend(flatten_tree(&node.children, depth + 1, expanded_dirs));
}
}
result
}
fn collect_all_dirs(nodes: &[FileTreeNode], dirs: &mut HashSet<PathBuf>) {
for node in nodes {
if node.is_dir {
dirs.insert(node.path.clone());
collect_all_dirs(&node.children, dirs);
}
}
}

View File

@ -24,8 +24,13 @@ fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) {
.enumerate()
.map(|(idx, item)| {
let indent = " ".repeat(item.depth);
let prefix = if item.node.is_dir { "📁 " } else if item.node.metadata.as_ref().map(|m| m.is_video).unwrap_or(false) { "🎥 " } else { "🎵 " };
let text = format!("{}{}{}", indent, prefix, item.node.name);
let expand_marker = if item.node.is_dir {
if item.is_expanded { "[-] " } else { "[+] " }
} else {
" "
};
let suffix = if item.node.is_dir { "/" } else { "" };
let text = format!("{}{}{}{}", indent, expand_marker, item.node.name, suffix);
let style = if idx == state.selected_index {
Style::default().fg(Color::Black).bg(Color::Cyan)
@ -114,12 +119,16 @@ fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) {
let help_text = vec![
Line::from(""),
Line::from(vec![
Span::styled("↑/↓", Style::default().fg(Color::Cyan)),
Span::raw(" Navigate"),
Span::styled("j/k", Style::default().fg(Color::Cyan)),
Span::raw(" Navigate down/up"),
]),
Line::from(vec![
Span::styled("h/l", Style::default().fg(Color::Cyan)),
Span::raw(" Collapse/expand dir"),
]),
Line::from(vec![
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" Play"),
Span::raw(" Play file"),
]),
Line::from(vec![
Span::styled("Space", Style::default().fg(Color::Cyan)),