Improve search and playlist management
- Add folder priority boost in fuzzy search scoring - Show search results in top status bar after Enter - Change keybindings: v for mark, a for add to playlist, c for clear - Add playlist management: add_to_playlist() and clear_playlist() - Fix playlist index reset when clearing playlist - Display incremental search status in bottom bar while typing
This commit is contained in:
330
src/state/mod.rs
330
src/state/mod.rs
@@ -27,6 +27,12 @@ pub struct AppState {
|
||||
pub playlist: Vec<PathBuf>,
|
||||
pub playlist_index: usize,
|
||||
pub is_refreshing: bool,
|
||||
pub search_mode: bool,
|
||||
pub search_query: String,
|
||||
pub search_matches: Vec<usize>,
|
||||
pub search_match_index: usize,
|
||||
pub tab_search_results: Vec<PathBuf>,
|
||||
pub tab_search_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -60,12 +66,22 @@ impl AppState {
|
||||
playlist: Vec::new(),
|
||||
playlist_index: 0,
|
||||
is_refreshing: false,
|
||||
search_mode: false,
|
||||
search_query: String::new(),
|
||||
search_matches: Vec::new(),
|
||||
search_match_index: 0,
|
||||
tab_search_results: Vec::new(),
|
||||
tab_search_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_selection_up(&mut self) {
|
||||
if self.selected_index > 0 {
|
||||
self.selected_index -= 1;
|
||||
// Scroll up when selection reaches top
|
||||
if self.selected_index < self.scroll_offset {
|
||||
self.scroll_offset = self.selected_index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +91,31 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_scroll_offset(&mut self, visible_height: usize) {
|
||||
// Scroll down when selection reaches bottom
|
||||
if self.selected_index >= self.scroll_offset + visible_height {
|
||||
self.scroll_offset = self.selected_index - visible_height + 1;
|
||||
}
|
||||
// Scroll up when selection reaches top
|
||||
if self.selected_index < self.scroll_offset {
|
||||
self.scroll_offset = self.selected_index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_down(&mut self) {
|
||||
// Move down by half page (vim Ctrl-D behavior)
|
||||
let half_page = 10; // Default half page size
|
||||
let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1));
|
||||
self.selected_index = new_index;
|
||||
}
|
||||
|
||||
pub fn page_up(&mut self) {
|
||||
// Move up by half page (vim Ctrl-U behavior)
|
||||
let half_page = 10; // Default half page size
|
||||
let new_index = self.selected_index.saturating_sub(half_page);
|
||||
self.selected_index = new_index;
|
||||
}
|
||||
|
||||
pub fn get_selected_item(&self) -> Option<&FlattenedItem> {
|
||||
self.flattened_items.get(self.selected_index)
|
||||
}
|
||||
@@ -130,6 +171,43 @@ impl AppState {
|
||||
self.marked_files.clear();
|
||||
}
|
||||
|
||||
pub fn clear_playlist(&mut self) {
|
||||
self.playlist.clear();
|
||||
self.playlist_index = 0;
|
||||
self.player_state = PlayerState::Stopped;
|
||||
self.current_file = None;
|
||||
}
|
||||
|
||||
pub fn add_to_playlist(&mut self) {
|
||||
// Add marked files or current selection to playlist
|
||||
if !self.marked_files.is_empty() {
|
||||
// Add marked files
|
||||
for path in &self.marked_files {
|
||||
if !self.playlist.contains(path) {
|
||||
self.playlist.push(path.clone());
|
||||
}
|
||||
}
|
||||
self.playlist.sort();
|
||||
} else if let Some(item) = self.get_selected_item() {
|
||||
let node = item.node.clone();
|
||||
if node.is_dir {
|
||||
// Add all files in directory
|
||||
let files = collect_files_from_node(&node);
|
||||
for path in files {
|
||||
if !self.playlist.contains(&path) {
|
||||
self.playlist.push(path);
|
||||
}
|
||||
}
|
||||
self.playlist.sort();
|
||||
} else {
|
||||
// Add single file
|
||||
if !self.playlist.contains(&node.path) {
|
||||
self.playlist.push(node.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_selection(&mut self) {
|
||||
// Priority: marked files > directory > single file
|
||||
if !self.marked_files.is_empty() {
|
||||
@@ -189,6 +267,197 @@ impl AppState {
|
||||
self.selected_index = self.flattened_items.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enter_search_mode(&mut self) {
|
||||
self.search_mode = true;
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_match_index = 0;
|
||||
self.tab_search_results.clear();
|
||||
self.tab_search_index = 0;
|
||||
}
|
||||
|
||||
pub fn exit_search_mode(&mut self) {
|
||||
self.search_mode = false;
|
||||
self.tab_search_results.clear();
|
||||
self.tab_search_index = 0;
|
||||
}
|
||||
|
||||
pub fn append_search_char(&mut self, c: char) {
|
||||
self.search_query.push(c);
|
||||
self.perform_incremental_search();
|
||||
}
|
||||
|
||||
pub fn backspace_search(&mut self) {
|
||||
self.search_query.pop();
|
||||
self.perform_incremental_search();
|
||||
}
|
||||
|
||||
fn perform_incremental_search(&mut self) {
|
||||
if self.search_query.is_empty() {
|
||||
self.tab_search_results.clear();
|
||||
self.tab_search_index = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all matching paths with scores from the entire tree
|
||||
let mut matching_paths_with_scores = Vec::new();
|
||||
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
|
||||
|
||||
if matching_paths_with_scores.is_empty() {
|
||||
self.tab_search_results.clear();
|
||||
self.tab_search_index = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by score (highest first)
|
||||
matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
// Store all matches for tab completion
|
||||
self.tab_search_results = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
|
||||
self.tab_search_index = 0;
|
||||
|
||||
// Expand parent directories of ALL matches (not just best match)
|
||||
// This ensures folders deep in the tree become visible
|
||||
for (path, _) in &matching_paths_with_scores {
|
||||
let mut parent = path.parent();
|
||||
while let Some(p) = parent {
|
||||
self.expanded_dirs.insert(p.to_path_buf());
|
||||
parent = p.parent();
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild flattened items
|
||||
self.rebuild_flattened_items();
|
||||
|
||||
// Find the best match in the flattened list and jump to it
|
||||
let best_match = &self.tab_search_results[0];
|
||||
if let Some(idx) = self.flattened_items.iter().position(|item| &item.node.path == best_match) {
|
||||
self.selected_index = idx;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_search(&mut self) {
|
||||
if self.search_query.is_empty() {
|
||||
self.search_mode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all matching paths with scores
|
||||
let mut matching_paths_with_scores = Vec::new();
|
||||
collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores);
|
||||
|
||||
if matching_paths_with_scores.is_empty() {
|
||||
self.search_mode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by score (highest first)
|
||||
matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
let matching_paths: Vec<PathBuf> = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect();
|
||||
|
||||
// Expand all parent directories
|
||||
for path in &matching_paths {
|
||||
let mut parent = path.parent();
|
||||
while let Some(p) = parent {
|
||||
self.expanded_dirs.insert(p.to_path_buf());
|
||||
parent = p.parent();
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild flattened items
|
||||
self.rebuild_flattened_items();
|
||||
|
||||
// Find indices of matches in the flattened list
|
||||
self.search_matches = matching_paths
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
self.flattened_items
|
||||
.iter()
|
||||
.position(|item| &item.node.path == path)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !self.search_matches.is_empty() {
|
||||
self.search_match_index = 0;
|
||||
self.selected_index = self.search_matches[0];
|
||||
}
|
||||
|
||||
self.search_mode = false;
|
||||
}
|
||||
|
||||
pub fn next_search_match(&mut self) {
|
||||
if !self.search_matches.is_empty() {
|
||||
self.search_match_index = (self.search_match_index + 1) % self.search_matches.len();
|
||||
self.selected_index = self.search_matches[self.search_match_index];
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev_search_match(&mut self) {
|
||||
if !self.search_matches.is_empty() {
|
||||
if self.search_match_index == 0 {
|
||||
self.search_match_index = self.search_matches.len() - 1;
|
||||
} else {
|
||||
self.search_match_index -= 1;
|
||||
}
|
||||
self.selected_index = self.search_matches[self.search_match_index];
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_search_next(&mut self) {
|
||||
if self.tab_search_results.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle to next match
|
||||
self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len();
|
||||
let next_match = self.tab_search_results[self.tab_search_index].clone();
|
||||
|
||||
// Expand parent directories
|
||||
let mut parent = next_match.parent();
|
||||
while let Some(p) = parent {
|
||||
self.expanded_dirs.insert(p.to_path_buf());
|
||||
parent = p.parent();
|
||||
}
|
||||
|
||||
// Rebuild flattened items
|
||||
self.rebuild_flattened_items();
|
||||
|
||||
// Find and select the match
|
||||
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) {
|
||||
self.selected_index = idx;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_search_prev(&mut self) {
|
||||
if self.tab_search_results.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle to previous match
|
||||
if self.tab_search_index == 0 {
|
||||
self.tab_search_index = self.tab_search_results.len() - 1;
|
||||
} else {
|
||||
self.tab_search_index -= 1;
|
||||
}
|
||||
let prev_match = self.tab_search_results[self.tab_search_index].clone();
|
||||
|
||||
// Expand parent directories
|
||||
let mut parent = prev_match.parent();
|
||||
while let Some(p) = parent {
|
||||
self.expanded_dirs.insert(p.to_path_buf());
|
||||
parent = p.parent();
|
||||
}
|
||||
|
||||
// Rebuild flattened items
|
||||
self.rebuild_flattened_items();
|
||||
|
||||
// Find and select the match
|
||||
if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) {
|
||||
self.selected_index = idx;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet<PathBuf>) -> Vec<FlattenedItem> {
|
||||
@@ -224,3 +493,64 @@ fn collect_files_from_node(node: &FileTreeNode) -> Vec<PathBuf> {
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
fn fuzzy_match(text: &str, query: &str) -> Option<i32> {
|
||||
let text_lower = text.to_lowercase();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut text_chars = text_lower.chars();
|
||||
let mut score = 0;
|
||||
let mut prev_match_idx = 0;
|
||||
let mut consecutive_bonus = 0;
|
||||
|
||||
for query_char in query_lower.chars() {
|
||||
let mut found = false;
|
||||
let mut current_idx = prev_match_idx;
|
||||
|
||||
for text_char in text_chars.by_ref() {
|
||||
current_idx += 1;
|
||||
if text_char == query_char {
|
||||
found = true;
|
||||
// Bonus for consecutive matches
|
||||
if current_idx == prev_match_idx + 1 {
|
||||
consecutive_bonus += 10;
|
||||
} else {
|
||||
consecutive_bonus = 0;
|
||||
}
|
||||
// Bonus for matching at word start
|
||||
if current_idx == 1 || text_lower.chars().nth(current_idx - 2).map_or(false, |c| !c.is_alphanumeric()) {
|
||||
score += 15;
|
||||
}
|
||||
score += consecutive_bonus;
|
||||
// Penalty for gap
|
||||
score -= (current_idx - prev_match_idx - 1) as i32;
|
||||
prev_match_idx = current_idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Bonus for shorter strings (better matches)
|
||||
score += 100 - text_lower.len() as i32;
|
||||
|
||||
Some(score)
|
||||
}
|
||||
|
||||
fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec<(PathBuf, i32)>) {
|
||||
for node in nodes {
|
||||
if let Some(mut score) = fuzzy_match(&node.name, query) {
|
||||
// Give folders a significant boost so they appear before files
|
||||
if node.is_dir {
|
||||
score += 50;
|
||||
}
|
||||
matches.push((node.path.clone(), score));
|
||||
}
|
||||
if node.is_dir && !node.children.is_empty() {
|
||||
collect_matching_paths(&node.children, query, matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user