From 8104f5488754d5d2473d0cae36a399a689ab43a4 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sat, 6 Dec 2025 12:39:11 +0100 Subject: [PATCH] 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 --- src/main.rs | 10 +++++-- src/state/mod.rs | 70 +++++++++++++++++++++++++++++++++++++++++++----- src/ui/mod.rs | 19 +++++++++---- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 07cc520..38c153b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/src/state/mod.rs b/src/state/mod.rs index ceb5dd7..752673c 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -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, + pub expanded_dirs: HashSet, } #[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 { +fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet) -> Vec { 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) { + for node in nodes { + if node.is_dir { + dirs.insert(node.path.clone()); + collect_all_dirs(&node.children, dirs); + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 628fc03..d3f08f6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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)),