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') => { KeyCode::Char('q') => {
state.should_quit = true; state.should_quit = true;
} }
KeyCode::Up => { KeyCode::Char('k') => {
state.move_selection_up(); state.move_selection_up();
} }
KeyCode::Down => { KeyCode::Char('j') => {
state.move_selection_down(); state.move_selection_down();
} }
KeyCode::Char('h') => {
state.collapse_selected();
}
KeyCode::Char('l') => {
state.expand_selected();
}
KeyCode::Enter => { KeyCode::Enter => {
if let Some(item) = state.get_selected_item() { if let Some(item) = state.get_selected_item() {
if !item.node.is_dir { if !item.node.is_dir {

View File

@ -1,5 +1,6 @@
use crate::cache::{Cache, FileTreeNode}; use crate::cache::{Cache, FileTreeNode};
use crate::config::Config; use crate::config::Config;
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -21,6 +22,7 @@ pub struct AppState {
pub volume: i64, pub volume: i64,
pub should_quit: bool, pub should_quit: bool,
pub flattened_items: Vec<FlattenedItem>, pub flattened_items: Vec<FlattenedItem>,
pub expanded_dirs: HashSet<PathBuf>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -32,7 +34,11 @@ pub struct FlattenedItem {
impl AppState { impl AppState {
pub fn new(cache: Cache, config: Config) -> Self { 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 { Self {
cache, cache,
@ -46,6 +52,7 @@ impl AppState {
volume: 100, volume: 100,
should_quit: false, should_quit: false,
flattened_items, flattened_items,
expanded_dirs,
} }
} }
@ -65,28 +72,79 @@ impl AppState {
self.flattened_items.get(self.selected_index) 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) { 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() { if self.selected_index >= self.flattened_items.len() {
self.selected_index = self.flattened_items.len().saturating_sub(1); 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(); let mut result = Vec::new();
for node in nodes { for node in nodes {
let is_expanded = expanded_dirs.contains(&node.path);
result.push(FlattenedItem { result.push(FlattenedItem {
node: node.clone(), node: node.clone(),
depth, depth,
is_expanded: true, // For now, all directories are expanded is_expanded,
}); });
if node.is_dir && !node.children.is_empty() { if node.is_dir && !node.children.is_empty() && is_expanded {
result.extend(flatten_tree(&node.children, depth + 1)); result.extend(flatten_tree(&node.children, depth + 1, expanded_dirs));
} }
} }
result 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() .enumerate()
.map(|(idx, item)| { .map(|(idx, item)| {
let indent = " ".repeat(item.depth); 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 expand_marker = if item.node.is_dir {
let text = format!("{}{}{}", indent, prefix, item.node.name); 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 { let style = if idx == state.selected_index {
Style::default().fg(Color::Black).bg(Color::Cyan) 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![ let help_text = vec![
Line::from(""), Line::from(""),
Line::from(vec![ Line::from(vec![
Span::styled("↑/↓", Style::default().fg(Color::Cyan)), Span::styled("j/k", Style::default().fg(Color::Cyan)),
Span::raw(" Navigate"), 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![ Line::from(vec![
Span::styled("Enter", Style::default().fg(Color::Cyan)), Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" Play"), Span::raw(" Play file"),
]), ]),
Line::from(vec![ Line::from(vec![
Span::styled("Space", Style::default().fg(Color::Cyan)), Span::styled("Space", Style::default().fg(Color::Cyan)),